| index cfec61f..94fb26f 100644 |
| --- a/web/static/apps/remind.js |
| +++ b/web/static/apps/remind.js |
| @@ -19,7 +19,7 @@ |
| // |
| // Relative import (not "/static/apps.js") so the parser + reducer are exercisable |
| // in Node tests while still resolving to /static/apps.js in the browser. |
| -import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; |
| +import { defineApp, addItem, updateItem, removeItem, partitionByGrace, DONE_GRACE_MS } from "../apps.js"; |
| |
| // ---- time parsing (pure, unit-tested) ----------------------------------- |
| |
| @@ -164,9 +164,11 @@ function dismiss(ctx, item) { |
| ctx.emit({ type: "remind.done", id: item.id }); |
| } |
| |
| -// A reminder is { id, text, when, ts, sid, fired }, folded soonest-first. |
| +// A reminder is { id, text, when, ts, sid, fired, firedAt }, folded soonest-first. |
| // addItem dedupes by id and returns a fresh array (so the sort is always safe); |
| -// remind.fire flags one as already alerted (so reopening won't re-notify); |
| +// remind.fire flags one as alerted and records WHEN it fired (firedAt = the |
| +// event's send time) — that drives the linger-then-tuck into "Previous reminders", |
| +// derived in the render (partitionByGrace), never by a timer mutating state. |
| // remind.done removes it. `sid` is the server schedule id ("" when scheduling |
| // failed and the client owns best-effort firing). |
| export const app = defineApp({ |
| @@ -178,11 +180,11 @@ export const app = defineApp({ |
| events: { |
| "remind.add": (s, e) => { |
| if (!e.id || !e.text || !e.when) return s; |
| - const items = addItem(s.items, { id: e.id, text: e.text, when: e.when, ts: e.ts || 0, sid: e.sid || "", fired: false }); |
| + const items = addItem(s.items, { id: e.id, text: e.text, when: e.when, ts: e.ts || 0, sid: e.sid || "", fired: false, firedAt: 0 }); |
| items.sort((a, b) => a.when - b.when); |
| return { items }; |
| }, |
| - "remind.fire": (s, e) => ({ items: updateItem(s.items, e.id, { fired: true }) }), |
| + "remind.fire": (s, e) => ({ items: updateItem(s.items, e.id, { fired: true, firedAt: e.ts || 0 }) }), |
| "remind.done": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| }, |
| commands: { |
| @@ -264,13 +266,34 @@ export const app = defineApp({ |
| .remind-item:hover .remind-remove, .remind-remove:focus-visible { opacity: 1; } |
| @media (pointer: coarse) { .remind-remove { opacity: 1; } } |
| .remind-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 18px 0; line-height: 1.5; } |
| +/* A just-fired reminder carries a depleting ring for the grace window before it |
| + tucks into "Previous reminders" — a visible "moving to history" countdown. */ |
| +.remind-ring { flex: 0 0 auto; width: 18px; height: 18px; margin-top: 1px; } |
| +.remind-ring-track { fill: none; stroke: var(--border); stroke-width: 2.5; } |
| +.remind-ring-fill { |
| + fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; |
| + stroke-dasharray: 62.83; stroke-dashoffset: 0; |
| + transform: rotate(-90deg); transform-origin: 50% 50%; |
| +} |
| +@keyframes remind-ring-deplete { to { stroke-dashoffset: 62.83; } } |
| +.remind-previous { margin-top: 8px; } |
| +.remind-previous-toggle { |
| + background: none; border: none; padding: 8px 2px; font: inherit; font-size: 13px; font-weight: 600; |
| + color: var(--muted); cursor: var(--ui-cursor, pointer); |
| +} |
| +.remind-previous-toggle:hover { color: var(--ink); } |
| `, |
| |
| // Personal scope: el is the full thread pane. Add form (mirrors the command), |
| - // then reminders soonest-first. A 1s ticker keeps the relative labels live and |
| - // fires anything now due — best-effort, while this pane is on screen. |
| + // then upcoming reminders soonest-first, then a collapsible "Previous reminders". |
| + // A 1s ticker keeps the relative labels live, fires anything now due (best-effort, |
| + // sid-less only), and repaints when a just-fired reminder tucks into Previous. |
| render(state, el, ctx) { |
| const items = (state && state.items) || []; |
| + const tid = ctx.threadId; |
| + // A fired reminder's completion time (firedAt); a legacy one (fired, no firedAt) |
| + // reads as long-fired (1) so it sits in Previous, not the upcoming list. |
| + const firedAt = (i) => (i.fired ? i.firedAt || 1 : 0); |
| |
| const pane = document.createElement("div"); |
| pane.className = "remind-pane"; |
| @@ -301,84 +324,175 @@ export const app = defineApp({ |
| hint.textContent = "e.g. “… in 30m”, “… at 9:00”, or “… | 2026-08-01 14:00”"; |
| pane.appendChild(hint); |
| |
| - if (!items.length) { |
| - const empty = document.createElement("div"); |
| - empty.className = "remind-empty"; |
| - empty.textContent = "No reminders yet. Add one above, or type /remind in any chat."; |
| - pane.appendChild(empty); |
| - } else { |
| - const list = document.createElement("ul"); |
| - list.className = "remind-list"; |
| - for (const item of items) { |
| - const li = document.createElement("li"); |
| - li.className = "remind-item" + (item.fired ? " fired" : ""); |
| - li.dataset.id = item.id; |
| - li.dataset.when = item.when; |
| - |
| - const main = document.createElement("div"); |
| - main.className = "remind-main"; |
| - |
| - const text = document.createElement("div"); |
| - text.className = "remind-text"; |
| - text.textContent = item.text; // user content — textContent, never innerHTML |
| - main.appendChild(text); |
| - |
| - const when = document.createElement("div"); |
| - when.className = "remind-when"; |
| - main.appendChild(when); |
| - |
| - // Absolute time for anything more than a day out (relative alone is vague). |
| - if (item.when - Date.now() > 86400000) { |
| - const abs = document.createElement("div"); |
| - abs.className = "remind-abs"; |
| - abs.textContent = absTime(item.when); |
| - main.appendChild(abs); |
| - } |
| + const listEl = document.createElement("div"); // upcoming + just-fired (grace) |
| + pane.appendChild(listEl); |
| + const previousEl = document.createElement("div"); |
| + previousEl.className = "remind-previous"; |
| + pane.appendChild(previousEl); |
| + el.appendChild(pane); |
| |
| - const rm = document.createElement("button"); |
| - rm.type = "button"; |
| - rm.className = "remind-remove"; |
| - rm.setAttribute("aria-label", "Remove reminder"); |
| - rm.textContent = "×"; |
| - rm.onclick = () => dismiss(ctx, item); |
| + // paint() rebuilds ONLY the list + previous containers (never the form), so the |
| + // ticker can repaint on a grace→Previous transition without clobbering typing. |
| + let archivedKey = null; |
| + const paint = () => { |
| + const now = Date.now(); |
| + const { active, grace, archived } = partitionByGrace(items, firedAt, now); |
| + const pending = [...active].sort((a, b) => a.when - b.when); // soonest first |
| + const settling = [...grace].sort((a, b) => (a.firedAt || 0) - (b.firedAt || 0)); |
| + const visible = [...pending, ...settling]; // upcoming, then just-fired (ringing) |
| + |
| + listEl.replaceChildren(); |
| + if (!visible.length) { |
| + const empty = document.createElement("div"); |
| + empty.className = "remind-empty"; |
| + empty.textContent = archived.length |
| + ? "No upcoming reminders." |
| + : "No reminders yet. Add one above, or type /remind in any chat."; |
| + listEl.appendChild(empty); |
| + } else { |
| + const ul = document.createElement("ul"); |
| + ul.className = "remind-list"; |
| + for (const item of visible) ul.appendChild(buildRow(item, now, ctx)); |
| + listEl.appendChild(ul); |
| + } |
| |
| - li.append(main, rm); |
| - list.appendChild(li); |
| + previousEl.replaceChildren(); |
| + if (archived.length) { |
| + const open = !!previousOpen[tid]; |
| + const toggle = document.createElement("button"); |
| + toggle.type = "button"; |
| + toggle.className = "remind-previous-toggle"; |
| + toggle.textContent = `${open ? "▾" : "▸"} Previous reminders (${archived.length})`; |
| + toggle.onclick = () => { |
| + previousOpen[tid] = !previousOpen[tid]; |
| + paint(); |
| + }; |
| + previousEl.appendChild(toggle); |
| + if (open) { |
| + const ul = document.createElement("ul"); |
| + ul.className = "remind-list"; |
| + for (const item of [...archived].sort((a, b) => (b.firedAt || 0) - (a.firedAt || 0))) { |
| + ul.appendChild(buildRow(item, now, ctx)); |
| + } |
| + previousEl.appendChild(ul); |
| + } |
| } |
| - pane.appendChild(list); |
| - } |
| + archivedKey = archived.map((i) => i.id).sort().join(","); |
| + }; |
| |
| - el.appendChild(pane); |
| + paint(); |
| |
| - // Live ticker: refresh the relative labels. Firing is the server's job now |
| - // (the scheduled remind.fire arrives like any event); the ticker only |
| - // client-fires the sid-less stragglers whose scheduling failed. Started via |
| + // Live ticker: refresh the upcoming relative labels, client-fire the sid-less |
| + // stragglers whose scheduling failed, and repaint when the archived set changes |
| + // (a just-fired reminder's grace expired) so it tucks into Previous. Started via |
| // ctx.interval, so the framework stops it on redraw / thread reset. |
| - const fired = new Set(); // guard against re-firing within this render's lifetime |
| - const tick = () => { |
| + const firedLocally = new Set(); // guard against re-firing within this render |
| + ctx.interval(() => { |
| const now = Date.now(); |
| - for (const li of pane.querySelectorAll(".remind-item")) { |
| + for (const li of listEl.querySelectorAll(".remind-item")) { |
| + if (li.classList.contains("fired")) continue; // settling/fired rows don't tick |
| const id = li.dataset.id; |
| const when = +li.dataset.when; |
| const whenEl = li.querySelector(".remind-when"); |
| - const isFired = li.classList.contains("fired"); |
| const due = now >= when; |
| if (whenEl) { |
| - whenEl.textContent = isFired ? "Reminded" : relTime(when, now); |
| - whenEl.classList.toggle("due", due && !isFired); |
| + whenEl.textContent = relTime(when, now); |
| + whenEl.classList.toggle("due", due); |
| } |
| - if (due && !isFired && !fired.has(id)) { |
| - fired.add(id); |
| + if (due && !firedLocally.has(id)) { |
| + firedLocally.add(id); |
| const it = items.find((x) => x.id === id); |
| if (it && !it.sid) fire(it, ctx); // no server schedule — client fallback |
| } |
| } |
| - }; |
| - tick(); |
| - ctx.interval(tick, 1000); |
| + const key = partitionByGrace(items, firedAt, now).archived.map((i) => i.id).sort().join(","); |
| + if (key !== archivedKey) paint(); |
| + }, 1000); |
| }, |
| }); |
| |
| +// The Previous-reminders section's expand state, per app-thread (module scope so |
| +// it survives repaints / re-renders — transient UI, not fold state). |
| +const previousOpen = {}; |
| + |
| +// buildRow renders one reminder row. Upcoming rows show a live countdown and a × |
| +// (cancel); a just-fired row shows a depleting ring (its grace window before it |
| +// tucks into Previous) plus the ×; a Previous row shows when it fired and the × |
| +// (delete from history). Reminders aren't tap-to-toggle — there's nothing to undo |
| +// about a reminder that already fired — so only the × acts. |
| +function buildRow(item, now, ctx) { |
| + const grace = !!(item.fired && item.firedAt && now - item.firedAt < DONE_GRACE_MS); |
| + const li = document.createElement("li"); |
| + li.className = "remind-item" + (item.fired ? " fired" : "") + (grace ? " grace" : ""); |
| + li.dataset.id = item.id; |
| + li.dataset.when = item.when; |
| + |
| + const main = document.createElement("div"); |
| + main.className = "remind-main"; |
| + const text = document.createElement("div"); |
| + text.className = "remind-text"; |
| + text.textContent = item.text; // user content — textContent, never innerHTML |
| + main.appendChild(text); |
| + |
| + const when = document.createElement("div"); |
| + when.className = "remind-when"; |
| + if (item.fired) { |
| + when.textContent = item.firedAt ? "Reminded · " + relTime(item.firedAt, now) : "Reminded"; |
| + } else { |
| + when.textContent = relTime(item.when, now); |
| + if (now >= item.when) when.classList.add("due"); |
| + } |
| + main.appendChild(when); |
| + |
| + // Absolute time for an upcoming reminder more than a day out (relative alone is vague). |
| + if (!item.fired && item.when - now > 86400000) { |
| + const abs = document.createElement("div"); |
| + abs.className = "remind-abs"; |
| + abs.textContent = absTime(item.when); |
| + main.appendChild(abs); |
| + } |
| + |
| + li.append(main); |
| + if (grace) li.append(graceRing(now - item.firedAt)); // depleting ring on its way to Previous |
| + li.append(removeButton(ctx, item)); |
| + return li; |
| +} |
| + |
| +function removeButton(ctx, item) { |
| + const rm = document.createElement("button"); |
| + rm.type = "button"; |
| + rm.className = "remind-remove"; |
| + rm.setAttribute("aria-label", item.fired ? "Delete from history" : "Cancel reminder"); |
| + rm.textContent = "×"; |
| + rm.onclick = () => dismiss(ctx, item); |
| + return rm; |
| +} |
| + |
| +// An SVG ring that depletes over the grace window (duration DONE_GRACE_MS so the |
| +// visual matches partitionByGrace). animation-delay is the negative elapsed time, |
| +// so it picks up mid-countdown on a repaint rather than restarting. |
| +function graceRing(elapsed) { |
| + const NS = "http://www.w3.org/2000/svg"; |
| + const svg = document.createElementNS(NS, "svg"); |
| + svg.setAttribute("class", "remind-ring"); |
| + svg.setAttribute("viewBox", "0 0 24 24"); |
| + svg.setAttribute("aria-hidden", "true"); |
| + const track = document.createElementNS(NS, "circle"); |
| + track.setAttribute("class", "remind-ring-track"); |
| + track.setAttribute("cx", "12"); |
| + track.setAttribute("cy", "12"); |
| + track.setAttribute("r", "10"); |
| + const fill = document.createElementNS(NS, "circle"); |
| + fill.setAttribute("class", "remind-ring-fill"); |
| + fill.setAttribute("cx", "12"); |
| + fill.setAttribute("cy", "12"); |
| + fill.setAttribute("r", "10"); |
| + fill.style.animation = `remind-ring-deplete ${DONE_GRACE_MS}ms linear forwards`; |
| + fill.style.animationDelay = `-${Math.max(0, Math.round(elapsed))}ms`; |
| + svg.append(track, fill); |
| + return svg; |
| +} |
| + |
| // fire is the offline fallback: when a reminder's server schedule failed (no |
| // sid), the render ticker fires it best-effort while this pane is on screen. It |
| // emits remind.fire WITH the notice, so an offline reminder reads exactly like a |