Task list with optional assignee; fix spending/task form submit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:56:22 +02:00
parent 6866316925
commit e013963bf7
2 changed files with 83 additions and 4 deletions

View File

@@ -260,7 +260,7 @@ function bestName(ref){
} }
// ---------- state ---------- // ---------- 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 state = EMPTY();
let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false; let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false;
const pid=(g,r)=>g+"__"+r; const pid=(g,r)=>g+"__"+r;
@@ -366,6 +366,7 @@ function renderTabs(){
<span class="bar">${["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("")}</span> <span class="bar">${["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("")}</span>
<span class="m"><span>${done}/${c.total} done</span><span>${c.miss?`<span style="color:var(--miss)">${c.miss} missing</span>`:(imageFor(G.key)?"drawing":"no drawing yet")}</span></span></button>`; <span class="m"><span>${done}/${c.total} done</span><span>${c.miss?`<span style="color:var(--miss)">${c.miss} missing</span>`:(imageFor(G.key)?"drawing":"no drawing yet")}</span></span></button>`;
}).join("") + `<button class="tab photos" role="tab" aria-selected="${current==="gallery"}" data-g="gallery"><span class="t">Photos</span><span class="m"><span>${Object.values(state.photos||{}).reduce((n,l)=>n+l.length,0)} photos</span><span></span></span></button>` }).join("") + `<button class="tab photos" role="tab" aria-selected="${current==="gallery"}" data-g="gallery"><span class="t">Photos</span><span class="m"><span>${Object.values(state.photos||{}).reduce((n,l)=>n+l.length,0)} photos</span><span></span></span></button>`
+ `<button class="tab" role="tab" aria-selected="${current==="tasks"}" data-g="tasks"><span class="t">Tasks</span><span class="m"><span>${(state.tasks||[]).filter(t=>!t.done).length} open</span><span>${(state.tasks||[]).filter(t=>!t.done&&t.to===state.me).length?`<span style="color:var(--accent)">${(state.tasks||[]).filter(t=>!t.done&&t.to===state.me).length} yours</span>`:""}</span></span></button>`
+ `<button class="tab" role="tab" aria-selected="${current==="spending"}" data-g="spending"><span class="t">Spending</span><span class="m"><span>${eur((state.spending||[]).reduce((n,x)=>n+x.cents,0))}</span><span></span></span></button>` + `<button class="tab" role="tab" aria-selected="${current==="spending"}" data-g="spending"><span class="t">Spending</span><span class="m"><span>${eur((state.spending||[]).reduce((n,x)=>n+x.cents,0))}</span><span></span></span></button>`
+ `<button class="tab" role="tab" aria-selected="${current==="activity"}" data-g="activity"><span class="t">Activity</span><span class="m"><span>who did what</span><span></span></span></button>` + `<button class="tab" role="tab" aria-selected="${current==="activity"}" data-g="activity"><span class="t">Activity</span><span class="m"><span>who did what</span><span></span></span></button>`
+ `<button class="tab shop" role="tab" aria-selected="${current==="shop"}" data-g="shop"><span class="t">Shopping list</span><span class="m"><span>${shopN} parts to get</span><span></span></span></button>`; + `<button class="tab shop" role="tab" aria-selected="${current==="shop"}" data-g="shop"><span class="t">Shopping list</span><span class="m"><span>${shopN} parts to get</span><span></span></span></button>`;
@@ -381,6 +382,7 @@ function visibleRefs(G){
function renderMain(){ function renderMain(){
if(current==="shop") return renderShop(); if(current==="shop") return renderShop();
if(current==="gallery") return renderGallery(); if(current==="gallery") return renderGallery();
if(current==="tasks") return renderTasks();
if(current==="spending") return renderSpending(); if(current==="spending") return renderSpending();
if(current==="activity") return renderActivity(); if(current==="activity") return renderActivity();
const G=GROUPS.find(x=>x.key===current); 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; } if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; }
const t=e.target.closest("button,[data-g]"); if(!t) 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.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; 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; } 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; } 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=>{ 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); } 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=>{ 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==="note"){ savePart(current,selRef,{note:e.target.value}); }
if((e.target.id==="pupl"||e.target.id==="gupl") && e.target.files.length){ 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:""; 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=>{ 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); const id=pid(current,selRef);
try{ try{
let r; let r;
@@ -530,6 +541,22 @@ document.addEventListener("submit",async e=>{
if(!r.queued) await reload(); if(!r.queued) await reload();
}catch(err){ toast(err.message); } }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 // marker drag + place
let drag=null, dragMoved=false; let drag=null, dragMoved=false;
document.addEventListener("pointerdown",e=>{ document.addEventListener("pointerdown",e=>{
@@ -564,6 +591,11 @@ function actText(a){
case "comment_del": return "deleted a comment"; case "comment_del": return "deleted a comment";
case "photo_add": return "added a photo"+(d?` “${d}”`:""); case "photo_add": return "added a photo"+(d?` “${d}”`:"");
case "photo_del": return "deleted a photo"; 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 "markers": return "moved markers on "+groupTitle(d);
case "spend_add": { const [m,...w]=d.split(" · "); return `spent €${m} on ${w.join(" · ")}`; } 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(" · ")}`; } 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(i<give.length&&j<get.length){ const m=Math.min(give[i].v,get[j].v); out.push({from:give[i].n,to:get[j].n,c:Math.round(m)}); give[i].v-=m; get[j].v-=m; if(give[i].v<0.5) i++; if(get[j].v<0.5) j++; } let i=0,j=0; while(i<give.length&&j<get.length){ const m=Math.min(give[i].v,get[j].v); out.push({from:give[i].n,to:get[j].n,c:Math.round(m)}); give[i].v-=m; get[j].v-=m; if(give[i].v<0.5) i++; if(get[j].v<0.5) j++; }
return out; return out;
} }
let taskFilter="open";
function renderTasks(){
const all=state.tasks||[], me=state.me, names=(state.users||[]).map(u=>u.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="")=>`<select ${id?`id="${id}"`:""} ${extra}><option value="">Anyone</option>${names.map(n=>`<option value="${esc(n)}"${n===v?" selected":""}>${esc(n)}</option>`).join("")}</select>`;
$("#main").innerHTML=`<section class="card shop">
<div><h2 style="font-size:20px">Tasks</h2><p class="rsub" style="margin:4px 0 0">A relaxed to-do list for the build. Assign something to someone, or leave it open for anyone.</p></div>
<form class="addrow" data-form="task"><input type="text" id="ttitle" placeholder="e.g. Strip the engine, order gaskets…" required maxlength="200" autocomplete="off">${sel("","tto")}<button class="btn primary" type="submit">Add</button></form>
<div class="filters">${[["open","Open"],["mine","Mine"],["done","Done"],["all","All"]].map(([k,l])=>`<button class="btn${taskFilter===k?" on":""}" data-tf="${k}">${l}</button>`).join("")}</div>
<div class="sub">${list.map(t=>`<div class="item" style="grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center">
<input type="checkbox" style="width:22px;height:22px" data-tdone="${t.id}" ${t.done?"checked":""} ${t.id<0?"disabled":""} aria-label="Done">
<span style="${t.done?"text-decoration:line-through;color:var(--muted)":""}"><b>${esc(t.title)}</b><br><span class="when">${t.done?`done by ${esc(t.doneBy)} · ${when(t.doneAt)}`:`added by ${esc(t.by)} · ${when(t.at)}`}${t.id<0?" · waiting to upload":""}</span></span>
${t.id>0&&!t.done?sel(t.to,"",`data-tto="${t.id}" aria-label="Assigned to"`):`<span class="chip">${esc(t.to||"anyone")}</span>`}
${t.id>0?`<button class="x" data-deltask="${t.id}" aria-label="Remove task">×</button>`:"<span></span>"}</div>`).join("")||`<div class="empty">${taskFilter==="mine"?"Nothing assigned to you.":"No tasks here."}</div>`}</div>
</section>`;
}
function renderSpending(){ function renderSpending(){
const sp=state.spending||[]; const names=(state.users||[]).map(u=>u.name); const n=Math.max(1,names.length); 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; const total=sp.reduce((a,x)=>a+x.cents,0), share=total/n;
@@ -757,7 +805,7 @@ document.addEventListener("click",async e=>{
})(); })();
// ---------- boot ---------- // ---------- 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(()=>{}); if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(()=>{});
window.addEventListener("online",()=>reload()); window.addEventListener("online",()=>reload());
window.addEventListener("offline",()=>{ online=false; badgeNow(); }); window.addEventListener("offline",()=>{ online=false; badgeNow(); });

View File

@@ -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 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 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 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); 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("andi", "lolo", true); // must choose a new one after the first login
addUser("lolo", "lolo", true); 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}$/; const NAME_RE = /^[a-z0-9_]{2,20}$/;
function checkLogin(name, pw) { function checkLogin(name, pw) {
const u = db.prepare("SELECT * FROM users WHERE name = ?").get(String(name || "").trim().toLowerCase()); 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 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 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 })); 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 ---------- // ---------- routes ----------
@@ -261,6 +265,33 @@ async function api(req, res, url) {
return send(res, 200, { ok: true }); 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") { if (req.method === "POST" && url.pathname === "/api/spending") {
const b = await readJson(req); const b = await readJson(req);
const cents = Math.round(Number(String(b.amount ?? "").replace(",", ".")) * 100); const cents = Math.round(Number(String(b.amount ?? "").replace(",", ".")) * 100);