| index a4470be..df81d61 100644 |
| --- a/web/static/apps/todo.js |
| +++ b/web/static/apps/todo.js |
| @@ -6,14 +6,23 @@ |
| // |
| // Relative import (not "/static/apps.js") so the reducer is exercisable in Node |
| // tests while still resolving to /static/apps.js in the browser. |
| -import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; |
| - |
| -// A to-do is { id, text, done, by }. The events map below IS the model: its keys |
| -// are the event types this app owns, its values fold each event into new state. |
| -// The collection helpers keep the fold idempotent (addItem dedupes by id) and |
| -// snapshot-safe (every op returns a new array) — so folding the same window twice |
| -// on reopen never duplicates, and two devices converge. `by` is the sender's |
| -// screen name, injected by the transport as `e.name`. |
| +import { defineApp, addItem, updateItem, removeItem, partitionByGrace } from "../apps.js"; |
| + |
| +// How long a checked-off to-do lingers (struck through, with its countdown ring) |
| +// before it tucks into Completed — short, since ticking a to-do is usually |
| +// deliberate. The ring depletes on a curve that moves immediately (so it visibly |
| +// starts the instant you tick) and then accelerates into the tuck. |
| +const TODO_GRACE_MS = 750; |
| +const RING_EASE = "cubic-bezier(0.3, 0.1, 0.7, 0.6)"; // immediate start, then speeds up |
| +const TUCK_AFTER_MS = 25; // beat between the ring finishing and the fold-up starting |
| + |
| +// A to-do is { id, text, done, doneAt, by }. The events map below IS the model: |
| +// its keys are the event types this app owns, its values fold each event into new |
| +// state. The collection helpers keep the fold idempotent (addItem dedupes by id) |
| +// and snapshot-safe (every op returns a new array). `doneAt` is the send time of |
| +// the check-off (e.ts) — it drives the "linger then tuck away" behaviour, but note |
| +// the REDUCER only records it: the grace-period / archive split is derived in the |
| +// render from the clock (see partitionByGrace), never by a timer mutating state. |
| export const app = defineApp({ |
| name: "todo", |
| title: "To-do", |
| @@ -21,9 +30,11 @@ export const app = defineApp({ |
| scope: "personal", |
| initialState: () => ({ items: [] }), |
| events: { |
| - "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, done: false, by: e.name || "" }) } : s), |
| - "todo.done": (s, e) => ({ items: updateItem(s.items, e.id, { done: true }) }), |
| - "todo.reopen": (s, e) => ({ items: updateItem(s.items, e.id, { done: false }) }), |
| + "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, done: false, doneAt: 0, by: e.name || "" }) } : s), |
| + // Record WHEN it was checked off (the sender's send time) so every device folds |
| + // the same doneAt and the grace→archive transition converges. |
| + "todo.done": (s, e) => ({ items: updateItem(s.items, e.id, { done: true, doneAt: e.ts || 0 }) }), |
| + "todo.reopen": (s, e) => ({ items: updateItem(s.items, e.id, { done: false, doneAt: 0 }) }), |
| "todo.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| }, |
| commands: { |
| @@ -62,17 +73,34 @@ export const app = defineApp({ |
| border: 1px solid var(--border); background: var(--panel); color: var(--ink); |
| } |
| .todo-add-btn { flex: 0 0 auto; } |
| -.todo-count { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; } |
| -.todo-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } |
| -.todo-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; margin: 0 -8px; border-radius: 8px; cursor: var(--ui-cursor, pointer); } |
| +.todo-count { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; margin-top: 8px; } |
| +.todo-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } |
| +/* Row spacing lives in the item's own margin (not a list gap) so a tucking row can |
| + animate its height, padding AND margin to zero and fully collapse — no residual |
| + gap for the rows below to jump over. */ |
| +.todo-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; margin: 0 -8px 4px; border-radius: 8px; cursor: var(--ui-cursor, pointer); } |
| .todo-item:hover { background: var(--bubble-them); } |
| .todo-check { |
| - flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; |
| + position: relative; flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; |
| border: 1.5px solid var(--border); background: transparent; color: var(--accent-ink); |
| font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; |
| cursor: var(--ui-cursor, pointer); padding: 0; |
| } |
| .todo-item.done .todo-check { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } |
| +/* During the grace period the circle carries a depleting ring around a ✓; both are |
| + drawn INSIDE one SVG (geometry, not a text glyph) so the tick is dead-centre and |
| + accent-coloured — no white-on-grey, no font-metric drift. Drop the fill/border so |
| + only the ring + tick show. */ |
| +.todo-item.grace .todo-check { background: transparent; border-color: transparent; } |
| +.todo-ring { position: absolute; inset: -1.5px; width: 23px; height: 23px; } |
| +.todo-ring-track { fill: none; stroke: var(--border); stroke-width: 2; } |
| +.todo-ring-fill { |
| + fill: none; stroke: var(--accent); stroke-width: 2; stroke-linecap: round; |
| + stroke-dasharray: 62.83; stroke-dashoffset: 0; |
| + transform: rotate(-90deg); transform-origin: 50% 50%; |
| +} |
| +.todo-ring-check { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; } |
| +@keyframes todo-ring-deplete { to { stroke-dashoffset: 62.83; } } |
| .todo-text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } |
| .todo-item.done .todo-text { text-decoration: line-through; color: var(--muted); } |
| .todo-remove { |
| @@ -82,11 +110,20 @@ export const app = defineApp({ |
| } |
| .todo-item:hover .todo-remove, .todo-remove:focus-visible { opacity: 1; } |
| @media (pointer: coarse) { .todo-remove { opacity: 1; } } |
| +.todo-completed { margin-top: 12px; } |
| +.todo-completed-toggle { |
| + background: none; border: none; padding: 6px 2px; font: inherit; font-size: 13px; font-weight: 600; |
| + color: var(--muted); cursor: var(--ui-cursor, pointer); |
| +} |
| +.todo-completed-toggle:hover { color: var(--ink); } |
| +.todo-list--done { margin-top: 2px; } |
| `, |
| |
| - // Full-pane render: an add row on top, a count line, then the checklist. |
| + // Full-pane render: add row, a count line, the live checklist (active items plus |
| + // ones inside their grace window), then a collapsible "Completed" section. |
| render(state, el, ctx) { |
| const items = (state && state.items) || []; |
| + const tid = ctx.threadId; |
| |
| const form = document.createElement("form"); |
| form.className = "todo-add"; |
| @@ -109,49 +146,220 @@ export const app = defineApp({ |
| }; |
| el.appendChild(form); |
| |
| - const open = items.filter((i) => !i.done).length; |
| const count = document.createElement("div"); |
| count.className = "todo-count"; |
| - count.textContent = items.length ? (open ? `${open} left` : "all done 🎉") : "No to-dos yet — add one above"; |
| el.appendChild(count); |
| |
| const list = document.createElement("ul"); |
| list.className = "todo-list"; |
| - // Newest on top: the fold appends new items, so render the list reversed. |
| - for (const item of [...items].reverse()) { |
| - const li = document.createElement("li"); |
| - li.className = "todo-item" + (item.done ? " done" : ""); |
| - // Tapping anywhere on the row checks it off (or reopens it) — the whole item |
| - // is the target, not just the little circle. Remove (×) stops the click from |
| - // also toggling (see its handler below). |
| - li.onclick = () => ctx.emit({ type: item.done ? "todo.reopen" : "todo.done", id: item.id }); |
| - |
| - const check = document.createElement("button"); |
| - check.type = "button"; |
| - check.className = "todo-check"; |
| - check.setAttribute("aria-label", item.done ? "Reopen" : "Mark done"); |
| - check.textContent = item.done ? "✓" : ""; |
| - // No own handler: the click bubbles to the row's toggle. Keeping it a real |
| - // <button> means it stays keyboard-focusable and labelled, so Space/Enter |
| - // on the circle checks the item off too. |
| - |
| - const text = document.createElement("span"); |
| - text.className = "todo-text"; |
| - text.textContent = item.text; // textContent — never innerHTML for user content |
| - |
| - const rm = document.createElement("button"); |
| - rm.type = "button"; |
| - rm.className = "todo-remove"; |
| - rm.setAttribute("aria-label", "Remove"); |
| - rm.textContent = "×"; |
| - rm.onclick = (e) => { |
| - e.stopPropagation(); // remove the item — don't also toggle the row |
| - ctx.emit({ type: "todo.remove", id: item.id }); |
| - }; |
| - |
| - li.append(check, text, rm); |
| - list.appendChild(li); |
| - } |
| el.appendChild(list); |
| + |
| + const completed = document.createElement("div"); |
| + completed.className = "todo-completed"; |
| + el.appendChild(completed); |
| + |
| + // paint() rebuilds ONLY the count + list + completed containers (never the |
| + // form), so the ticker can repaint on a grace→archive transition without |
| + // clobbering what you're typing. A done item with no doneAt (from before this |
| + // feature) reads as long-archived (doneAt = 1), so old completed items tuck |
| + // straight into the Completed section rather than reappearing as active. |
| + const doneAt = (i) => (i.done ? i.doneAt || 1 : 0); |
| + let archivedKey = null; |
| + const paint = () => { |
| + const now = Date.now(); |
| + const { active, archived } = partitionByGrace(items, doneAt, now, TODO_GRACE_MS); |
| + |
| + count.textContent = items.length ? (active.length ? `${active.length} left` : "all done 🎉") : "No to-dos yet — add one above"; |
| + |
| + // Visible list = everything not yet archived, newest-first, IN PLACE (a |
| + // just-checked item stays where it sits, struck-through with its ring). |
| + const archivedIds = new Set(archived.map((i) => i.id)); |
| + list.replaceChildren(); |
| + for (const item of [...items].reverse()) { |
| + if (!archivedIds.has(item.id)) list.appendChild(buildRow(item, now, ctx)); |
| + } |
| + |
| + completed.replaceChildren(); |
| + if (archived.length) { |
| + const open = !!completedOpen[tid]; |
| + const toggle = document.createElement("button"); |
| + toggle.type = "button"; |
| + toggle.className = "todo-completed-toggle"; |
| + toggle.textContent = `${open ? "▾" : "▸"} Completed (${archived.length})`; |
| + toggle.onclick = () => { |
| + completedOpen[tid] = !completedOpen[tid]; |
| + paint(); |
| + }; |
| + completed.appendChild(toggle); |
| + if (open) { |
| + const ul = document.createElement("ul"); |
| + ul.className = "todo-list todo-list--done"; |
| + for (const item of [...archived].sort((a, b) => (b.doneAt || 0) - (a.doneAt || 0))) { |
| + ul.appendChild(buildRow(item, now, ctx)); |
| + } |
| + completed.appendChild(ul); |
| + } |
| + } |
| + archivedKey = [...archivedIds].sort().join(","); |
| + }; |
| + |
| + paint(); |
| + |
| + // When a grace window expires the row folds up — height/padding/margin ease to |
| + // zero, sliding the rows below smoothly up — and only THEN do we repaint, so the |
| + // item tucks into Completed without the list jumping. We fire on a PRECISE timer |
| + // (the next grace item's expiry + a tiny beat), not a poll, so the fold follows |
| + // the ring immediately instead of lagging up to a second behind it. |
| + let folding = false; |
| + let tuckTimer = null; |
| + const scheduleTuck = () => { |
| + if (tuckTimer) clearTimeout(tuckTimer); |
| + tuckTimer = null; |
| + const grace = partitionByGrace(items, doneAt, Date.now(), TODO_GRACE_MS).grace; |
| + if (!grace.length) return; // nothing lingering — nothing to tuck |
| + const nextExpiry = Math.min(...grace.map((i) => (i.doneAt || 0) + TODO_GRACE_MS)); |
| + tuckTimer = setTimeout(tuckExpired, Math.max(0, nextExpiry - Date.now()) + TUCK_AFTER_MS); |
| + }; |
| + const tuckExpired = () => { |
| + tuckTimer = null; |
| + if (folding) return; // a fold in flight will re-schedule when it finishes |
| + const now = Date.now(); |
| + const archived = partitionByGrace(items, doneAt, now, TODO_GRACE_MS).archived; |
| + if (archived.map((i) => i.id).sort().join(",") === archivedKey) { |
| + scheduleTuck(); |
| + return; |
| + } |
| + const nowArchived = new Set(archived.map((i) => i.id)); |
| + const rows = [...list.querySelectorAll(".todo-item")].filter((li) => nowArchived.has(li.dataset.id)); |
| + if (!rows.length || reducedMotion()) { |
| + paint(); |
| + scheduleTuck(); |
| + return; |
| + } |
| + folding = true; |
| + let remaining = rows.length; |
| + const finish = () => { |
| + if (--remaining > 0) return; |
| + folding = false; |
| + paint(); |
| + scheduleTuck(); // line up the next grace item's tuck |
| + }; |
| + rows.forEach((li) => foldUpRow(li, finish)); |
| + }; |
| + scheduleTuck(); |
| + ctx.onCleanup(() => { |
| + if (tuckTimer) clearTimeout(tuckTimer); |
| + }); |
| }, |
| }); |
| + |
| +// The completed section's expand state, per app-thread (module scope so it |
| +// survives repaints and re-renders — it's transient UI, not fold state). |
| +const completedOpen = {}; |
| + |
| +// buildRow renders one to-do row. Tapping anywhere on the row toggles done/reopen |
| +// (so a tap during the grace period is the "undo"); the × removes it. A row inside |
| +// its grace window shows a depleting ring around the ✓ — a visible countdown until |
| +// it tucks into Completed. |
| +function buildRow(item, now, ctx) { |
| + const grace = !!(item.done && item.doneAt && now - item.doneAt < TODO_GRACE_MS); |
| + const li = document.createElement("li"); |
| + li.className = "todo-item" + (item.done ? " done" : "") + (grace ? " grace" : ""); |
| + li.dataset.id = item.id; // so the ticker can find the row to fold up on expiry |
| + if (grace) li.title = "Tap to undo"; |
| + li.onclick = () => ctx.emit({ type: item.done ? "todo.reopen" : "todo.done", id: item.id }); |
| + |
| + const check = document.createElement("button"); |
| + check.type = "button"; |
| + check.className = "todo-check"; |
| + check.setAttribute("aria-label", item.done ? "Reopen" : "Mark done"); |
| + // No own handler: the click bubbles to the row's toggle. Keeping it a real |
| + // <button> means it stays keyboard-focusable and labelled (Space/Enter works). |
| + if (grace) { |
| + check.appendChild(graceRing(now - item.doneAt)); // ring + centred ✓, one SVG |
| + } else { |
| + check.textContent = item.done ? "✓" : ""; |
| + } |
| + |
| + const text = document.createElement("span"); |
| + text.className = "todo-text"; |
| + text.textContent = item.text; // textContent — never innerHTML for user content |
| + |
| + const rm = document.createElement("button"); |
| + rm.type = "button"; |
| + rm.className = "todo-remove"; |
| + rm.setAttribute("aria-label", "Remove"); |
| + rm.textContent = "×"; |
| + rm.onclick = (e) => { |
| + e.stopPropagation(); // remove the item — don't also toggle the row |
| + ctx.emit({ type: "todo.remove", id: item.id }); |
| + }; |
| + |
| + li.append(check, text, rm); |
| + return li; |
| +} |
| + |
| +// An SVG holding the depleting ring AND a centred ✓ check mark (drawn geometry, so |
| +// the tick is exactly centred — no font-metric drift). The ring drains over the |
| +// grace window on RING_EASE (moves immediately, then speeds up); animation-delay is |
| +// the negative elapsed time so it picks up mid-countdown on a repaint (or reopen), |
| +// and the duration is TODO_GRACE_MS so the visual and partitionByGrace stay locked. |
| +function graceRing(elapsed) { |
| + const NS = "http://www.w3.org/2000/svg"; |
| + const svg = document.createElementNS(NS, "svg"); |
| + svg.setAttribute("class", "todo-ring"); |
| + svg.setAttribute("viewBox", "0 0 24 24"); |
| + svg.setAttribute("aria-hidden", "true"); |
| + const track = document.createElementNS(NS, "circle"); |
| + track.setAttribute("class", "todo-ring-track"); |
| + track.setAttribute("cx", "12"); |
| + track.setAttribute("cy", "12"); |
| + track.setAttribute("r", "10"); |
| + const fill = document.createElementNS(NS, "circle"); |
| + fill.setAttribute("class", "todo-ring-fill"); |
| + fill.setAttribute("cx", "12"); |
| + fill.setAttribute("cy", "12"); |
| + fill.setAttribute("r", "10"); |
| + fill.style.animation = `todo-ring-deplete ${TODO_GRACE_MS}ms ${RING_EASE} forwards`; |
| + fill.style.animationDelay = `-${Math.max(0, Math.round(elapsed))}ms`; |
| + const check = document.createElementNS(NS, "path"); |
| + check.setAttribute("class", "todo-ring-check"); |
| + check.setAttribute("d", "M7.5 12.5 L10.5 15.5 L16.5 8.5"); // ✓ centred on (12,12) |
| + svg.append(track, fill, check); |
| + return svg; |
| +} |
| + |
| +// Whether the OS asks us to minimise motion — the fold-up is skipped if so. |
| +function reducedMotion() { |
| + try { |
| + return typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| +// foldUpRow collapses a row to nothing with an ease-in curve — height, padding and |
| +// margin all animate to 0 (overflow hidden), so the row folds away and the rows |
| +// below slide smoothly up. Calls done() when the height transition finishes (with a |
| +// timeout backstop). The row is discarded by the repaint that follows. |
| +function foldUpRow(li, done) { |
| + const h = li.offsetHeight; |
| + li.style.overflow = "hidden"; |
| + li.style.height = h + "px"; |
| + void li.offsetHeight; // reflow so the start height sticks before we transition |
| + li.style.transition = "height 0.26s ease-in, opacity 0.2s ease-in, margin 0.26s ease-in, padding 0.26s ease-in"; |
| + li.style.height = "0px"; |
| + li.style.opacity = "0"; |
| + li.style.marginTop = "0"; |
| + li.style.marginBottom = "0"; |
| + li.style.paddingTop = "0"; |
| + li.style.paddingBottom = "0"; |
| + let called = false; |
| + const fin = () => { |
| + if (called) return; |
| + called = true; |
| + done(); |
| + }; |
| + li.addEventListener("transitionend", (e) => { if (e.propertyName === "height") fin(); }, { once: true }); |
| + setTimeout(fin, 320); |
| +} |