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 = '