eleven / ocho Public

web: reminders — fired ones tuck into a "Previous reminders" section (#217)

* web: to-do — completed items linger with a countdown ring, then tuck away

Checking off a to-do now records doneAt (the send time) and the item lingers in
place, struck-through with a depleting ring, for DONE_GRACE_MS — tap to undo
(reopen) — then tucks into a collapsible "Completed (n)" section.

- apps.js: partitionByGrace(items, doneAtOf, now, graceMs) — pure, the whole
  "linger then archive" rule, so the reducer only records doneAt and the fold
  stays deterministic (no timer mutates state). DONE_GRACE_MS exported.
- todo.js: todo.done records doneAt (todo.reopen clears it); render partitions
  active + grace (in place, with the ring) vs archived (Completed section). A 1s
  ticker repaints ONLY the list containers (never the form) when a grace window
  expires, so typing isn't clobbered and the CSS ring doesn't restart each second.
  A legacy done item (no doneAt) reads as long-archived, so it tucks away rather
  than reappearing as active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — snappier grace (ease-in ring) + eased fold-up on tuck

Polish on the completed-items behaviour:
- Shorter grace (TODO_GRACE_MS = 2.5s, was the 6s framework default) so a ticked
  to-do clears sooner, and the ring now depletes ease-in (slow → quick) so the
  last moment feels snappy. Grace is passed explicitly to partitionByGrace +
  graceRing, so reminders keep the default.
- When the grace expires the row folds up — height/padding/margin ease to zero
  (overflow hidden), sliding the rows below smoothly upward — before the repaint,
  so the item tucks into Completed without the list jumping. Row spacing moved
  from the list's `gap` to per-item margin so it can fully collapse. Reduced
  motion skips the animation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — grace ✓ visible (accent, not white-on-grey) and centred

During the grace window the check circle is transparent (so the ring shows), but
the ✓ was still accent-ink (white) → invisible on the grey row. Colour it accent,
and absolutely centre the tick inside the circle so it sits dead-centre in the ring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — SVG-drawn centred ✓, 1.5s grace, ring starts immediately

- The grace ✓ is now a path drawn inside the ring SVG (not a text glyph), so it's
  geometrically centred and accent-coloured — fixes the off-centre, white-on-grey ✓.
- Grace shortened to 1.5s.
- Ring easing → cubic-bezier(0.3,0.1,0.7,0.6): it moves immediately when you tick
  (no flat ease-in dead-zone) and then accelerates into the tuck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — 750ms grace + tuck follows the ring immediately (precise timer)

- Grace halved again to 750ms.
- Replace the 1s poll that detected grace expiry (which left up to ~1s pause
  between the ring finishing and the fold-up) with a precise timer fired at the
  next grace item's expiry + TUCK_AFTER_MS (25ms), so the tuck follows the ring
  right away. Timer is cleared on re-render/thread reset via ctx.onCleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: reminders — fired ones tuck into a "Previous reminders" section

A reminder that fires now records firedAt (the event's send time) and lingers
briefly with a depleting ring, then tucks into a collapsible "Previous reminders
(n)" section — instead of either lingering struck-through forever or vanishing.
Upcoming reminders stay in the main list, soonest-first.

- remind.js: remind.fire records firedAt; render partitions via partitionByGrace
  (shared with to-do) into upcoming + settling (ring) vs archived (Previous). A 1s
  ticker updates upcoming labels, client-fires sid-less stragglers, and repaints
  only the list/previous containers (never the form) when a grace window expires.
  Reminders aren't tap-to-toggle (nothing to undo about a fired one) — only × acts
  (cancel an upcoming one / delete a previous one). Legacy fired items (no firedAt)
  read as long-fired, so they land in Previous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 1d9d8ae3716ed01e106bfeef80ec891a8897ad9f parent 36294c6
3 files changed, +183 −66
  • changelog/2026-07-05-reminders-previous-section.md +1 −0
  • test/apps.test.mjs +3 −1
  • web/static/apps/remind.js +179 −65
diff --git a/changelog/2026-07-05-reminders-previous-section.md b/changelog/2026-07-05-reminders-previous-section.md
new file mode 100644
index 0000000..8331aa3
--- /dev/null
+++ b/changelog/2026-07-05-reminders-previous-section.md
@@ -0,0 +1 @@
+Reminders that have gone off no longer pile up in your list. When one fires it lingers briefly — with a little ring counting down — then tucks itself into a “Previous reminders” section at the bottom, which you can expand any time to look back over what you've been reminded about (or clear items from it). Your upcoming reminders stay front and centre, soonest first.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 7ba0df0..9ec7484 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -486,8 +486,10 @@ await test("remind: reduce adds (soonest first), fires, and dismisses", () => {
assert.equal(s.items.length, 1);
s = remindReduce(s, { type: "remind.add", id: "r2", text: "sooner", when: 100, ts: 2 });
assert.equal(s.items[0].id, "r2", "sorted soonest-first");
- s = remindReduce(s, { type: "remind.fire", id: "r2" });
+ // fire records firedAt (the event's send time) so it can tuck into Previous.
+ s = remindReduce(s, { type: "remind.fire", id: "r2", ts: 555 });
assert.equal(s.items.find((i) => i.id === "r2").fired, true);
+ assert.equal(s.items.find((i) => i.id === "r2").firedAt, 555);
s = remindReduce(s, { type: "remind.done", id: "r2" });
assert.deepEqual(s.items.map((i) => i.id), ["r1"]);
});
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
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