diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml index 856a232..e1ce4de 100644 --- a/glance-system/glance-helper.yaml +++ b/glance-system/glance-helper.yaml @@ -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,18 +1106,73 @@ 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", [])) + "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" + ) + @APP.post("/userdata/{user}/todos") def add_todo(user: str, body: TodoItem, key: str = Query(default="")): """Add a new todo item.""" @@ -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" ) diff --git a/glance-system/glance-kisfenyo.yaml b/glance-system/glance-kisfenyo.yaml index 591a1c5..835e6a1 100644 --- a/glance-system/glance-kisfenyo.yaml +++ b/glance-system/glance-kisfenyo.yaml @@ -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'); - - async function addTodo() { - const text = input.value.trim(); - if (!text) return; - input.disabled = true; - try { - const r = await fetch(apiUrl('/userdata/' + USER + '/todos'), { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({text: text, done: false}) - }); - 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; + function escHtml(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; } + function generateId() { + return Math.random().toString(36).substr(2, 8); + } + + function nowISO() { + return new Date().toISOString(); + } + + // ===== LocalStorage Operations ===== + function loadLocal() { + try { + 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 = '
No tasks yet
'; + return; + } + listEl.innerHTML = localData.todos.map(function(t) { + return '
' + + '
' + + '' + escHtml(t.text) + '' + + '' + + '
'; + }).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({ + todos: localData.todos, + modified: localMod + }) + }); + 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 (addBtn) { - addBtn.addEventListener('click', function() { - if (input.value.trim()) addTodo(); - else input.focus(); + if (e.key === 'Enter') { + e.preventDefault(); + const text = input.value.trim(); + if (text) { + addTodo(text); + input.value = ''; + } + } }); } - 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" }}