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:
@@ -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(); });
|
||||
|
||||
33
server.js
33
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);
|
||||
|
||||
Reference in New Issue
Block a user