diff --git a/public/index.html b/public/index.html index 9db3e83..a902bfc 100644 --- a/public/index.html +++ b/public/index.html @@ -260,7 +260,7 @@ function bestName(ref){ } // ---------- state ---------- -const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}, users:[], me:"", spending:[], activity:[]}); +const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}, users:[], me:"", spending:[], activity:[], tasks:[]}); let state = EMPTY(); let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false; const pid=(g,r)=>g+"__"+r; @@ -366,6 +366,7 @@ function renderTabs(){ ${["have","skip","ord","miss"].map(s=>``).join("")} ${done}/${c.total} done${c.miss?`${c.miss} missing`:(imageFor(G.key)?"drawing":"no drawing yet")}`; }).join("") + `` + + `` + `` + `` + ``; @@ -381,6 +382,7 @@ function visibleRefs(G){ function renderMain(){ if(current==="shop") return renderShop(); if(current==="gallery") return renderGallery(); + if(current==="tasks") return renderTasks(); if(current==="spending") return renderSpending(); if(current==="activity") return renderActivity(); const G=GROUPS.find(x=>x.key===current); @@ -482,6 +484,9 @@ document.addEventListener("click",async e=>{ if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; } const t=e.target.closest("button,[data-g]"); if(!t) return; if(t.dataset.go){ const [g,r]=t.dataset.go.split("|"); current=g; selRef=r; filter="all"; render(); try{localStorage.setItem(LS+"-tab",current)}catch(_){} return; } + if(t.dataset.tf){ taskFilter=t.dataset.tf; render(); return; } + if(t.dataset.deltask){ const id=+t.dataset.deltask; if(!confirm("Remove this task?")) return; + try{ const r=await send("DELETE","api/tasks/"+id,undefined,()=>{ state.tasks=state.tasks.filter(x=>x.id!==id); }); if(!r.queued) await reload(); }catch(err){ toast("Couldn’t remove: "+err.message); } return; } if(t.dataset.delspend){ const id=+t.dataset.delspend; if(!confirm("Remove this spending entry?")) return; try{ const r=await send("DELETE","api/spending/"+id,undefined,()=>{ state.spending=state.spending.filter(x=>x.id!==id); }); if(!r.queued) await reload(); }catch(err){ toast("Couldn’t remove: "+err.message); } return; } if(t.dataset.ph){ const [sc,i]=t.dataset.ph.split("|"); openPhotos(photoItems(sc),+i); return; } @@ -506,7 +511,13 @@ document.addEventListener("click",async e=>{ document.addEventListener("input",e=>{ if(e.target.id==="q"){ query=e.target.value.trim(); const pos=e.target.selectionStart; renderMain(); const q=$("#q"); q.focus(); q.setSelectionRange(pos,pos); } }); +async function taskUpdate(id,patch){ + try{ const r=await send("PUT","api/tasks/"+id,patch,()=>{ const t=state.tasks.find(x=>x.id===id); if(t){ if(patch.done!==undefined){ t.done=patch.done; t.doneBy=patch.done?state.me:""; t.doneAt=patch.done?Date.now():0; } if(patch.to!==undefined) t.to=patch.to; } }); + if(!r.queued) await reload(); }catch(err){ toast("Couldn’t save: "+err.message); await reload(); } +} document.addEventListener("change",async e=>{ + if(e.target.dataset.tdone){ taskUpdate(+e.target.dataset.tdone,{done:e.target.checked}); return; } + if(e.target.dataset.tto){ taskUpdate(+e.target.dataset.tto,{to:e.target.value}); return; } if(e.target.id==="note"){ savePart(current,selRef,{note:e.target.value}); } if((e.target.id==="pupl"||e.target.id==="gupl") && e.target.files.length){ const files=[...e.target.files], scope=e.target.id==="gupl"?"gallery":pid(current,selRef), cap=e.target.id==="gupl"?($("#gcap")||{}).value:""; @@ -519,7 +530,7 @@ document.addEventListener("change",async e=>{ } }); document.addEventListener("submit",async e=>{ - const f=e.target.closest("form[data-form]"); if(!f) return; e.preventDefault(); if(f.dataset.form==="spend") return; + const f=e.target.closest("form[data-form]"); if(!f) return; e.preventDefault(); if(f.dataset.form==="spend"||f.dataset.form==="task") return; const id=pid(current,selRef); try{ let r; @@ -530,6 +541,22 @@ document.addEventListener("submit",async e=>{ if(!r.queued) await reload(); }catch(err){ toast(err.message); } }); +// spending + task forms +document.addEventListener("submit",async e=>{ + const f=e.target.closest('form[data-form="spend"],form[data-form="task"]'); if(!f) return; e.preventDefault(); + try{ + let r; + if(f.dataset.form==="spend"){ + const b={amount:$("#samt").value,what:$("#swhat").value}; const cents=Math.round(Number(b.amount.replace(",","."))*100); + if(!(cents>0)){ toast("Enter an amount greater than 0"); return; } + r=await send("POST","api/spending",b,()=>{ state.spending.unshift({id:tmpId--,by:state.me,cents,what:b.what,at:Date.now()}); }); toast(r.queued?"Saved offline, will upload later":"Added"); + } else { + const b={title:$("#ttitle").value,to:$("#tto").value}; + r=await send("POST","api/tasks",b,()=>{ state.tasks.unshift({id:tmpId--,title:b.title,to:b.to,done:false,by:state.me,at:Date.now(),doneBy:"",doneAt:0}); }); toast(r.queued?"Saved offline, will upload later":"Task added"); + } + if(!r.queued) await reload(); + }catch(err){ toast(err.message); } +}); // marker drag + place let drag=null, dragMoved=false; document.addEventListener("pointerdown",e=>{ @@ -564,6 +591,11 @@ function actText(a){ case "comment_del": return "deleted a comment"; case "photo_add": return "added a photo"+(d?` “${d}”`:""); case "photo_del": return "deleted a photo"; + case "task_add": return `added the task “${d}”`; + case "task_done": return `finished “${d}”`; + case "task_open": return `reopened “${d}”`; + case "task_assign": return `assigned “${d}”`; + case "task_del": return `removed the task “${d}”`; case "markers": return "moved markers on "+groupTitle(d); case "spend_add": { const [m,...w]=d.split(" · "); return `spent €${m} on ${w.join(" · ")}`; } case "spend_del": { const [m,...w]=d.split(" · "); return `removed the spending €${m} ${w.join(" · ")}`; } @@ -575,6 +607,22 @@ function settlements(bal){ let i=0,j=0; while(iu.name); + const list=all.filter(t=>taskFilter==="all"||(taskFilter==="open"&&!t.done)||(taskFilter==="mine"&&!t.done&&t.to===me)||(taskFilter==="done"&&t.done)); + const sel=(v,id,extra="")=>``; + $("#main").innerHTML=`
+

Tasks

A relaxed to-do list for the build. Assign something to someone, or leave it open for anyone.

+
${sel("","tto")}
+
${[["open","Open"],["mine","Mine"],["done","Done"],["all","All"]].map(([k,l])=>``).join("")}
+
${list.map(t=>`
+ + ${esc(t.title)}
${t.done?`done by ${esc(t.doneBy)} · ${when(t.doneAt)}`:`added by ${esc(t.by)} · ${when(t.at)}`}${t.id<0?" · waiting to upload":""}
+ ${t.id>0&&!t.done?sel(t.to,"",`data-tto="${t.id}" aria-label="Assigned to"`):`${esc(t.to||"anyone")}`} + ${t.id>0?``:""}
`).join("")||`
${taskFilter==="mine"?"Nothing assigned to you.":"No tasks here."}
`}
+
`; +} function renderSpending(){ const sp=state.spending||[]; const names=(state.users||[]).map(u=>u.name); const n=Math.max(1,names.length); const total=sp.reduce((a,x)=>a+x.cents,0), share=total/n; @@ -757,7 +805,7 @@ document.addEventListener("click",async e=>{ })(); // ---------- boot ---------- -try{ const t=localStorage.getItem(LS+"-tab"); if(t && (t==="shop"||t==="gallery"||t==="spending"||t==="activity"||GROUPS.some(g=>g.key===t))) current=t; }catch(_){} +try{ const t=localStorage.getItem(LS+"-tab"); if(t && (t==="shop"||t==="gallery"||t==="tasks"||t==="spending"||t==="activity"||GROUPS.some(g=>g.key===t))) current=t; }catch(_){} if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(()=>{}); window.addEventListener("online",()=>reload()); window.addEventListener("offline",()=>{ online=false; badgeNow(); }); diff --git a/server.js b/server.js index bb0cb92..728fcb4 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ db.exec(` CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, must_change INTEGER NOT NULL DEFAULT 0, ver INTEGER NOT NULL DEFAULT 1, last_seen INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, part_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS activity_part ON activity(part_id); + CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, assignee TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, author TEXT NOT NULL, created INTEGER NOT NULL, done_by TEXT NOT NULL DEFAULT '', done_at INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS spending (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, cents INTEGER NOT NULL, what TEXT NOT NULL, created INTEGER NOT NULL); `); @@ -62,6 +63,7 @@ if (!db.prepare("SELECT 1 FROM users LIMIT 1").get()) { addUser("andi", "lolo", true); // must choose a new one after the first login addUser("lolo", "lolo", true); } +const userOrBlank = (n) => { n = String(n || "").trim().toLowerCase(); return n && db.prepare("SELECT 1 FROM users WHERE name = ?").get(n) ? n : ""; }; const NAME_RE = /^[a-z0-9_]{2,20}$/; function checkLogin(name, pw) { const u = db.prepare("SELECT * FROM users WHERE name = ?").get(String(name || "").trim().toLowerCase()); @@ -176,7 +178,9 @@ function fullState(me) { const users = db.prepare("SELECT name, last_seen FROM users ORDER BY name").all().map((u) => ({ name: u.name, online: u.name === me || now - u.last_seen < 90000, last: u.last_seen })); const spending = db.prepare("SELECT id, author, cents, what, created FROM spending ORDER BY created DESC, id DESC").all().map((r) => ({ id: r.id, by: r.author, cents: r.cents, what: r.what, at: r.created })); const activity = db.prepare("SELECT id, author, part_id, action, detail, created FROM activity ORDER BY id DESC LIMIT 300").all().map((r) => ({ id: r.id, by: r.author, part: r.part_id, action: r.action, detail: r.detail, at: r.created })); - return { parts, links, comments, drawings, photos, users, me, spending, activity }; + const tasks = db.prepare("SELECT id, title, assignee, done, author, created, done_by, done_at FROM tasks ORDER BY done, created DESC, id DESC").all() + .map((r) => ({ id: r.id, title: r.title, to: r.assignee, done: !!r.done, by: r.author, at: r.created, doneBy: r.done_by, doneAt: r.done_at })); + return { parts, links, comments, drawings, photos, users, me, spending, activity, tasks }; } // ---------- routes ---------- @@ -261,6 +265,33 @@ async function api(req, res, url) { return send(res, 200, { ok: true }); } + if (req.method === "POST" && url.pathname === "/api/tasks") { + const b = await readJson(req); const title = cleanText(b.title, 200).trim(); + if (!title) return fail(res, 400, "Write what needs doing"); + const to = userOrBlank(b.to); + const r = db.prepare("INSERT INTO tasks (title, assignee, author, created) VALUES (?, ?, ?, ?)").run(title, to, req.user.name, Date.now()); + logActivity(req.user.name, "", "task_add", to ? `${title} → ${to}` : title); + return send(res, 201, { id: Number(r.lastInsertRowid) }); + } + if (req.method === "PUT" && (p = m(/^\/api\/tasks\/(\d+)$/))) { + const old = db.prepare("SELECT * FROM tasks WHERE id = ?").get(+p[1]); if (!old) return fail(res, 404, "Task not found"); + const b = await readJson(req); + const to = b.to !== undefined ? userOrBlank(b.to) : old.assignee; + const done = b.done !== undefined ? (b.done ? 1 : 0) : old.done; + const title = b.title !== undefined ? (cleanText(b.title, 200).trim() || old.title) : old.title; + db.prepare("UPDATE tasks SET title = ?, assignee = ?, done = ?, done_by = ?, done_at = ? WHERE id = ?") + .run(title, to, done, done ? (old.done ? old.done_by : req.user.name) : "", done ? (old.done ? old.done_at : Date.now()) : 0, old.id); + if (done !== old.done) logActivity(req.user.name, "", done ? "task_done" : "task_open", title); + else if (to !== old.assignee) logActivity(req.user.name, "", "task_assign", to ? `${title} → ${to}` : `${title} → nobody`); + return send(res, 200, { ok: true }); + } + if (req.method === "DELETE" && (p = m(/^\/api\/tasks\/(\d+)$/))) { + const old = db.prepare("SELECT title FROM tasks WHERE id = ?").get(+p[1]); + db.prepare("DELETE FROM tasks WHERE id = ?").run(+p[1]); + if (old) logActivity(req.user.name, "", "task_del", old.title); + return send(res, 200, { ok: true }); + } + if (req.method === "POST" && url.pathname === "/api/spending") { const b = await readJson(req); const cents = Math.round(Number(String(b.amount ?? "").replace(",", ".")) * 100);