local cache
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = '<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, '"') + '" 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({
|
||||
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" }}
|
||||
|
||||
<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; }
|
||||
|
||||
Reference in New Issue
Block a user