local cache

This commit is contained in:
2026-02-02 07:43:17 +01:00
parent 18c47b6ff7
commit 795b9c03ef
2 changed files with 271 additions and 57 deletions
+68 -4
View File
@@ -1019,6 +1019,11 @@ data:
text: str
done: bool = False
class TodoSyncRequest(BaseModel):
"""Request body for syncing todos."""
todos: list
modified: str # Client's last modified timestamp
class MotivationItem(BaseModel):
text: str
@@ -1038,6 +1043,7 @@ data:
default = {
"notes": "",
"todos": [],
"todos_modified": None, # ISO timestamp of last todos modification
"motivation": [
"Believe in yourself!",
"Every day is a new opportunity.",
@@ -1100,13 +1106,68 @@ data:
)
# ========== TODOS ==========
def _update_todos_modified(data: dict) -> str:
"""Update the todos_modified timestamp and return it."""
ts = datetime.now(timezone.utc).isoformat()
data["todos_modified"] = ts
return ts
@APP.get("/userdata/{user}/todos")
def get_todos(user: str):
"""Get user todos."""
"""Get user todos with modification timestamp for sync."""
data = _load_userdata(user)
return Response(
content=json.dumps({
"todos": data.get("todos", []),
"count": len(data.get("todos", [])),
"modified": data.get("todos_modified")
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
@APP.post("/userdata/{user}/todos/sync")
def sync_todos(user: str, body: TodoSyncRequest, key: str = Query(default="")):
"""
Sync todos between client and server.
- If client's modified > server's modified: update server with client data
- If server's modified > client's modified: return server data (client should update)
- Returns current server state either way
"""
_verify_key(key)
data = _load_userdata(user)
server_modified = data.get("todos_modified")
client_modified = body.modified
# Determine who has newer data
# If server has no timestamp, client data is newer
# If client timestamp > server timestamp, client is newer
client_is_newer = False
if not server_modified:
client_is_newer = True
elif client_modified and client_modified > server_modified:
client_is_newer = True
if client_is_newer:
# Update server with client data
data["todos"] = body.todos
new_ts = _update_todos_modified(data)
_save_userdata(user, data)
return Response(
content=json.dumps({
"action": "uploaded",
"todos": data["todos"],
"modified": new_ts,
"count": len(data["todos"])
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
else:
# Server has newer or same data, client should download
return Response(
content=json.dumps({
"action": "downloaded",
"todos": data.get("todos", []),
"modified": server_modified,
"count": len(data.get("todos", []))
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
@@ -1128,10 +1189,11 @@ data:
if not data.get("todos"):
data["todos"] = []
data["todos"].append(new_todo)
new_ts = _update_todos_modified(data)
_save_userdata(user, data)
return Response(
content=json.dumps({"success": True, "todo": new_todo}, ensure_ascii=False),
content=json.dumps({"success": True, "todo": new_todo, "modified": new_ts}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
@@ -1145,9 +1207,10 @@ data:
if todo.get("id") == todo_id:
todo["text"] = body.text.strip()
todo["done"] = body.done
new_ts = _update_todos_modified(data)
_save_userdata(user, data)
return Response(
content=json.dumps({"success": True, "todo": todo}, ensure_ascii=False),
content=json.dumps({"success": True, "todo": todo, "modified": new_ts}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
@@ -1165,9 +1228,10 @@ data:
if len(data["todos"]) == original_count:
raise HTTPException(status_code=404, detail="Todo not found")
new_ts = _update_todos_modified(data)
_save_userdata(user, data)
return Response(
content=json.dumps({"success": True, "deleted": todo_id}, ensure_ascii=False),
content=json.dumps({"success": True, "deleted": todo_id, "modified": new_ts}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
+196 -46
View File
@@ -306,9 +306,12 @@ data:
})();
// ================================
// Todo Widget Controller
// Todo Widget Controller (Local-first with background sync)
// ================================
(function() {
const SYNC_INTERVAL = 60000; // 60 seconds
const STORAGE_KEY_PREFIX = 'glance-todos-';
function initTodoWidgets() {
document.querySelectorAll('.todo-widget').forEach(function(widget) {
if (widget.dataset.initialized) return;
@@ -317,70 +320,214 @@ data:
const API = widget.dataset.api;
const USER = widget.dataset.user;
const KEY = widget.dataset.key;
const STORAGE_KEY = STORAGE_KEY_PREFIX + USER;
const listEl = widget.querySelector('.todo-list');
const input = widget.querySelector('.todo-add-input');
const addBtn = widget.querySelector('.todo-add-btn');
// Local state
let localData = { todos: [], modified: null, serverModified: null };
let syncInProgress = false;
function apiUrl(path) {
return API + path + (KEY ? '?key=' + KEY : '');
}
const input = widget.querySelector('.todo-add-input');
const addBtn = widget.querySelector('.todo-add-btn');
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function addTodo() {
const text = input.value.trim();
if (!text) return;
input.disabled = true;
function generateId() {
return Math.random().toString(36).substr(2, 8);
}
function nowISO() {
return new Date().toISOString();
}
// ===== LocalStorage Operations =====
function loadLocal() {
try {
const r = await fetch(apiUrl('/userdata/' + USER + '/todos'), {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
localData = JSON.parse(stored);
}
} catch(e) { console.error('Load local error:', e); }
}
function saveLocal() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(localData));
} catch(e) { console.error('Save local error:', e); }
}
// ===== DOM Rendering =====
function render() {
if (!localData.todos || localData.todos.length === 0) {
listEl.innerHTML = '<div class="todo-empty">No tasks yet</div>';
return;
}
listEl.innerHTML = localData.todos.map(function(t) {
return '<div class="todo-item" data-id="' + t.id + '" data-text="' + escHtml(t.text).replace(/"/g, '&quot;') + '" data-done="' + t.done + '">' +
'<div class="todo-checkbox ' + (t.done ? 'done' : '') + '"></div>' +
'<span class="todo-text ' + (t.done ? 'done' : '') + '">' + escHtml(t.text) + '</span>' +
'<button class="todo-delete" type="button" title="Delete">🗑</button>' +
'</div>';
}).join('');
}
// ===== Sync with Server =====
async function syncWithServer() {
if (syncInProgress) return;
syncInProgress = true;
try {
// First, fetch current server state
const getResp = await fetch(API + '/userdata/' + USER + '/todos');
if (!getResp.ok) {
console.error('Fetch todos failed');
syncInProgress = false;
return;
}
const serverData = await getResp.json();
const serverMod = serverData.modified;
// Determine sync action
const localMod = localData.modified;
const lastKnownServerMod = localData.serverModified;
// If server has newer data than our last known server state
if (serverMod && (!lastKnownServerMod || serverMod > lastKnownServerMod)) {
// Server has updates (possibly from another device)
// Check if we also have local changes
if (localMod && localMod > lastKnownServerMod) {
// Conflict: both have changes. Server wins (source of truth)
console.log('Sync: Server has newer data, downloading');
}
// Download server data
localData.todos = serverData.todos || [];
localData.modified = serverMod;
localData.serverModified = serverMod;
saveLocal();
render();
} else if (localMod && (!lastKnownServerMod || localMod > lastKnownServerMod)) {
// We have local changes that server doesn't know about
console.log('Sync: Uploading local changes');
const syncResp = await fetch(apiUrl('/userdata/' + USER + '/todos/sync'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, done: false})
body: JSON.stringify({
todos: localData.todos,
modified: localMod
})
});
if (r.ok) location.reload();
else console.error('Todo add failed:', await r.text());
} catch(e) { console.error('Todo add error:', e); }
input.disabled = false;
if (syncResp.ok) {
const result = await syncResp.json();
localData.serverModified = result.modified;
localData.modified = result.modified;
if (result.action === 'downloaded') {
// Server had newer data after all
localData.todos = result.todos;
render();
}
saveLocal();
}
}
// else: no changes needed
} catch(e) {
console.error('Sync error:', e);
}
syncInProgress = false;
}
// ===== Local Operations (immediate) =====
function addTodo(text) {
const newTodo = {
id: generateId(),
text: text,
done: false,
created: nowISO()
};
if (!localData.todos) localData.todos = [];
localData.todos.push(newTodo);
localData.modified = nowISO();
saveLocal();
render();
// Sync in background (don't await)
syncWithServer();
}
function toggleTodo(id) {
const todo = localData.todos.find(function(t) { return t.id === id; });
if (todo) {
todo.done = !todo.done;
localData.modified = nowISO();
saveLocal();
render();
syncWithServer();
}
}
function deleteTodo(id) {
localData.todos = localData.todos.filter(function(t) { return t.id !== id; });
localData.modified = nowISO();
saveLocal();
render();
syncWithServer();
}
// ===== Event Handlers =====
if (input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addTodo(); }
});
if (e.key === 'Enter') {
e.preventDefault();
const text = input.value.trim();
if (text) {
addTodo(text);
input.value = '';
}
}
if (addBtn) {
addBtn.addEventListener('click', function() {
if (input.value.trim()) addTodo();
else input.focus();
});
}
widget.addEventListener('click', async function(e) {
if (addBtn) {
addBtn.addEventListener('click', function() {
const text = input.value.trim();
if (text) {
addTodo(text);
input.value = '';
} else {
input.focus();
}
});
}
widget.addEventListener('click', function(e) {
const item = e.target.closest('.todo-item');
if (!item) return;
if (e.target.closest('.todo-checkbox')) {
const id = item.dataset.id;
const text = item.dataset.text;
const newDone = item.dataset.done !== 'true';
try {
const r = await fetch(apiUrl('/userdata/' + USER + '/todos/' + id), {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, done: newDone})
});
if (r.ok) location.reload();
} catch(e) { console.error('Todo toggle error:', e); }
toggleTodo(item.dataset.id);
}
if (e.target.closest('.todo-delete')) {
const id = item.dataset.id;
try {
const r = await fetch(apiUrl('/userdata/' + USER + '/todos/' + id), {
method: 'DELETE'
});
if (r.ok) location.reload();
} catch(e) { console.error('Todo delete error:', e); }
deleteTodo(item.dataset.id);
}
});
// ===== Initialize =====
// 1. Load from localStorage immediately
loadLocal();
render();
// 2. Sync with server
syncWithServer();
// 3. Set up periodic sync
setInterval(syncWithServer, SYNC_INTERVAL);
});
}
@@ -389,7 +536,6 @@ data:
} else {
initTodoWidgets();
}
// Also run after a delay for dynamically loaded content
setTimeout(initTodoWidgets, 500);
setTimeout(initTodoWidgets, 1500);
})();
@@ -1008,21 +1154,25 @@ data:
{{ $todos := .JSON.Array "todos" }}
<style>
.todo-widget { display: flex; flex-direction: column; gap: 8px; }
.todo-add { display: flex; gap: 8px; align-items: center; }
.todo-widget { display: flex; flex-direction: column; gap: 10px; }
.todo-add {
display: flex; gap: 8px; align-items: center;
background: rgba(255,255,255,0.06); border-radius: 8px;
padding: 4px 4px 4px 8px;
}
.todo-add-btn {
background: none; border: none; color: inherit; opacity: 0.6;
cursor: pointer; font-size: 18px; padding: 4px 8px;
background: none; border: none; color: inherit; opacity: 0.7;
cursor: pointer; font-size: 18px; padding: 4px;
transition: opacity 0.15s;
}
.todo-add-btn:hover { opacity: 1; }
.todo-add-input {
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; padding: 8px 10px; color: inherit; font-size: 13px;
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px; padding: 10px 12px; color: inherit; font-size: 13px;
outline: none; transition: border-color 0.15s, background 0.15s;
}
.todo-add-input:focus {
background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25);
background: rgba(255,255,255,0.12); border-color: rgba(96, 165, 250, 0.5);
}
.todo-add-input::placeholder { opacity: 0.5; }
.todo-list { display: flex; flex-direction: column; gap: 2px; }