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 ----------
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(){
<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>`;
}).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==="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>`;
@@ -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(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;
}
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(){
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(); });