feat(celebrations): a burst for each thing finished, and a switch for all of it

The redesign only celebrated a finished section, so ticking one daily or
completing an event drew nothing. Now a daily ticked, a routine met, an
amount typed to its target and an event marked completed each fire the
small burst; the item that finishes every daily or a game's routines gets
only the grand celebration. Settings → Appearance → Celebrations
(prefs.celebrations, on unless false) turns every firework off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 22:46:46 +02:00
1 parent 185ccb2507
commit f17b7985ef
11 files changed
+734 -527

No files matched your search

+4
View File
@@ -828,6 +828,10 @@ means the reader has not taken this tour: the app moves them to `interface: "rel
`tourMode` in `ReturningTour.tsx`. `tourMode` in `ReturningTour.tsx`.
`prefs.celebrations` (2026-10-18) is the Settings → Appearance switch for fireworks. Absent or
`true` means on; only `false` turns every celebration off, big and small. See `celebrationsOn` in
`Fireworks.tsx`.
The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`) The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`)
for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the
interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved. interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved.
+14 -7
View File
@@ -499,9 +499,11 @@ text. On focus the border moves to faint. The field gets no glow, because a form
system. Each offers one action. system. Each offers one action.
### Daily burst ### Daily burst
A small spray of 3px sparks that fires once, when the last daily of the day is ticked. It is purely A small spray of 3px sparks that fires once, in the row, when a reader finishes one thing: a daily
decorative and ignores pointer input. With reduced motion requested it does not render at all, ticked, a routine met, or an event marked completed (in its "Moved to Archive" notice). Finishing a
rather than being toned down. whole section gets the grand version instead (see the Checklist's *Celebration*). It is purely
decorative and ignores pointer input. With reduced motion requested, or with Settings → Appearance →
Celebrations switched off, it does not render at all, rather than being toned down.
## Do's and Don'ts ## Do's and Don'ts
@@ -616,11 +618,16 @@ legend on the same baseline as "Next: …".
or over the target completes it after a 450ms pause. or over the target completes it after a 450ms pause.
- **Completion animation.** The bar fills, the row warms, then the box pops. Unticking undoes it: - **Completion animation.** The bar fills, the row warms, then the box pops. Unticking undoes it:
the amount from before goes back, or the editor opens when that value isn't known. the amount from before goes back, or the editor opens when that value isn't known.
- **Celebration.** A single completion fires live's small `Fireworks` in the row. Finishing a whole - **Celebration.** A single completion fires live's small `Fireworks` in the row: a daily ticked in
section (every daily, or every routine in a game's group) fires `Fireworks grand`: seven bursts in the panel, a pinned routine met, an amount typed up to its target. Finishing a whole section
two waves with the game's hue mixed in, a light sweep, and the "All done" line popping. (every daily, or every routine in a game's group) fires `Fireworks grand`: seven bursts in two
waves with the game's hue mixed in, a light sweep, and the "All done" line popping. The item that
finishes the section gets only the grand one, not its own burst on top. Marking an event completed
fires the small burst in the "Moved to Archive" notice; ignoring one fires nothing.
- **When it fires.** Only an action taken now that completes the item or section, never arriving - **When it fires.** Only an action taken now that completes the item or section, never arriving
complete. Reduced motion removes all of it. complete. Reduced motion removes all of it, and so does `prefs.celebrations: false` (Settings →
Appearance → Celebrations), read through `CelebrationsContext` in `Fireworks.tsx`. The row's own
tick animation (wash, pop, check) stays either way: it is feedback, not celebration.
### Other pages ### Other pages
+3 -1
View File
@@ -482,7 +482,9 @@ scheme are independent. Custom stores ground/surface/text/accent separately for
rendered colours derive readable contrast without overwriting the saved choices. Per-game colours rendered colours derive readable contrast without overwriting the saved choices. Per-game colours
have a picker, hex field, preview and reset. Checklist dailies can occupy a separate direct-tick panel have a picker, hex field, preview and reset. Checklist dailies can occupy a separate direct-tick panel
or grouped routines, with collapsed/expanded/always-expanded policies; these controls also live in or grouped routines, with collapsed/expanded/always-expanded policies; these controls also live in
Routines. Numeric routines use native step-one inputs. Routines. Numeric routines use native step-one inputs. A **Celebrations** switch (on by default)
covers the fireworks: a small burst for finishing one daily, routine or event, and the grand one for
finishing every daily or every routine in a game's group. Off means none at all, big or small.
The earlier theme rationale below still applies to both interfaces. The earlier theme rationale below still applies to both interfaces.
The app is a lit instrument panel and that is what it should be on first sight, but it is also read The app is a lit instrument panel and that is what it should be on first sight, but it is also read
+518 -492
View File
File diff suppressed because it is too large. Load diff
+17 -2
View File
@@ -1,6 +1,8 @@
import { createContext, useContext } from "react";
/** /**
* A small burst, for the one moment in this app that is unambiguously good * A small burst, for finishing something: a daily ticked, a routine met, an
* news: every daily ticked off before the reset. * event marked completed. Finishing a whole section gets the grand version.
* *
* Deliberately tiny and deliberately silent — decorative only, `aria-hidden`, * Deliberately tiny and deliberately silent — decorative only, `aria-hidden`,
* and gone in under a second. The text beside it already says "All done", which * and gone in under a second. The text beside it already says "All done", which
@@ -11,6 +13,17 @@
* and the state it celebrates is legible without it. * and the state it celebrates is legible without it.
*/ */
/**
* Whether the reader wants celebrations at all (Settings → Appearance). Off
* means none, big or small; absent means on.
*/
export function celebrationsOn(prefs: Record<string, unknown>): boolean {
return prefs.celebrations !== false;
}
/** The reader's celebrations switch, provided once at the app's root. */
export const CelebrationsContext = createContext(true);
/** /**
* Fixed spark geometry rather than random: the burst looks the same every time, * Fixed spark geometry rather than random: the burst looks the same every time,
* which makes it a piece of the interface rather than a surprise, and keeps it * which makes it a piece of the interface rather than a surprise, and keeps it
@@ -57,6 +70,8 @@ export function Fireworks({
/** A game's colour to mix into the sparks. */ /** A game's colour to mix into the sparks. */
hue?: string | undefined; hue?: string | undefined;
} = {}) { } = {}) {
const on = useContext(CelebrationsContext);
if (!on) return null;
const bursts = grand ? GRAND_BURSTS : BURSTS, const bursts = grand ? GRAND_BURSTS : BURSTS,
sparks = grand ? GRAND_SPARKS : SPARKS, sparks = grand ? GRAND_SPARKS : SPARKS,
colors = hue ? [hue, ...COLORS] : COLORS; colors = hue ? [hue, ...COLORS] : COLORS;
@@ -9,6 +9,7 @@ import { timelineLook } from "./TimelinePage.tsx";
import { ViewCards } from "./ViewCards.tsx"; import { ViewCards } from "./ViewCards.tsx";
import { focusBarShown } from "./FocusPicker.tsx"; import { focusBarShown } from "./FocusPicker.tsx";
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts"; import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
import { celebrationsOn } from "../Fireworks.tsx";
/** The live legend's words for each step. */ /** The live legend's words for each step. */
const URGENCY_LABEL = { const URGENCY_LABEL = {
@@ -353,6 +354,22 @@ export function AppearancePage({
</label> </label>
))} ))}
</section> </section>
<section className="settings-group">
<h2>Celebrations</h2>
<p className="help">
Fireworks when you finish a daily, a routine or an event, and a bigger
show when you finish all your dailies or a game's routines.
</p>
<label>
<input
type="checkbox"
data-celebrations
checked={celebrationsOn(p)}
onChange={(e) => patch({ celebrations: e.target.checked })}
/>
Show fireworks
</label>
</section>
<section className="settings-group checklist-settings"> <section className="settings-group checklist-settings">
<h2>Checklist</h2> <h2>Checklist</h2>
<div className="setting-fields"> <div className="setting-fields">
+68 -16
View File
@@ -8,7 +8,10 @@ import { streakOf } from "../../../shared/daily.ts";
import { goalCount } from "../../../shared/goal.ts"; import { goalCount } from "../../../shared/goal.ts";
import { chipLabel } from "../Dailies.tsx"; import { chipLabel } from "../Dailies.tsx";
import { Fireworks } from "../Fireworks.tsx"; import { Fireworks } from "../Fireworks.tsx";
import { useSectionCelebration } from "./useSectionCelebration.ts"; import {
finishesSection,
useSectionCelebration,
} from "./useSectionCelebration.ts";
import type { ReaderState } from "../../state/reader/schema.ts"; import type { ReaderState } from "../../state/reader/schema.ts";
import type { RoutineRow } from "../../state/reader/routines.ts"; import type { RoutineRow } from "../../state/reader/routines.ts";
@@ -18,19 +21,24 @@ export function RoutineControl({
row, row,
onAction, onAction,
now, now,
finishes = false,
}: { }: {
row: RoutineRow; row: RoutineRow;
onAction: ActionHandler; onAction: ActionHandler;
now: number; now: number;
/** Completing it finishes the section, whose grand celebration goes instead. */
finishes?: boolean;
}) { }) {
const today = readerDayKey(now, row.clock); const today = readerDayKey(now, row.clock);
const [amount, setAmount] = useState(String(row.status.count)); const [amount, setAmount] = useState(String(row.status.count));
const editing = useRef(false); const editing = useRef(false);
const [burst, celebrate] = useBurst();
useEffect(() => { useEffect(() => {
if (!editing.current) setAmount(String(row.status.count)); if (!editing.current) setAmount(String(row.status.count));
}, [row.status.count, row.status.periodStart]); }, [row.status.count, row.status.periodStart]);
return ( return (
<div className="routine-control"> <div className="routine-control">
{burst > 0 && <Fireworks key={burst} />}
<div> <div>
<strong>{row.title}</strong> <strong>{row.title}</strong>
<small> <small>
@@ -58,16 +66,22 @@ export function RoutineControl({
onChange={(e) => { onChange={(e) => {
setAmount(e.target.value); setAmount(e.target.value);
const value = e.target.valueAsNumber; const value = e.target.valueAsNumber;
if (Number.isInteger(value) && value >= 0) if (!Number.isInteger(value) || value < 0) return;
void onAction({ if (
type: "amount", !finishes &&
accountId: row.accountId, row.status.count < row.status.required &&
revisionId: row.clock.revision, value >= row.status.required
itemId: row.id, )
period: row.status.periodStart, celebrate();
value, void onAction({
...actionMeta(), type: "amount",
}); accountId: row.accountId,
revisionId: row.clock.revision,
itemId: row.id,
period: row.status.periodStart,
value,
...actionMeta(),
});
}} }}
/> />
</label> </label>
@@ -77,7 +91,11 @@ export function RoutineControl({
className="daily-tick" className="daily-tick"
aria-label={`${row.title}, ${row.days.includes(today) ? "done" : "not done"} today`} aria-label={`${row.title}, ${row.days.includes(today) ? "done" : "not done"} today`}
aria-pressed={row.days.includes(today)} aria-pressed={row.days.includes(today)}
onClick={() => toggleToday(row, today, onAction)} onClick={() => {
if (!finishes && !row.days.includes(today) && !row.status.met)
celebrate();
toggleToday(row, today, onAction);
}}
> >
<svg aria-hidden viewBox="0 0 16 16"> <svg aria-hidden viewBox="0 0 16 16">
<rect <rect
@@ -103,6 +121,21 @@ export function RoutineControl({
</div> </div>
); );
} }
/**
* The small burst: fired by an action that completes one item, cleared once
* the sparks are done (the live strip's 1400ms). Counts up so a second
* completion while one is still showing remounts it.
*/
function useBurst(): [number, () => void] {
const [burst, setBurst] = useState(0);
useEffect(() => {
if (burst === 0) return;
const id = setTimeout(() => setBurst(0), 1400);
return () => clearTimeout(id);
}, [burst]);
return [burst, () => setBurst((n) => n + 1)];
}
/** Today's tick for a daily row: on, or off again. */ /** Today's tick for a daily row: on, or off again. */
function toggleToday(row: RoutineRow, today: string, onAction: ActionHandler) { function toggleToday(row: RoutineRow, today: string, onAction: ActionHandler) {
void onAction({ void onAction({
@@ -139,9 +172,10 @@ function doneToday(row: RoutineRow, today: string): boolean {
/** /**
* Today's dailies in the Checklist rail. The whole row is the tick target, as * Today's dailies in the Checklist rail. The whole row is the tick target, as
* the live strip's pill is. A tick washes the row in the game's colour, pops * the live strip's pill is. A tick washes the row in the game's colour, pops
* the box and draws the check. The live strip's fireworks go off when the last * the box and draws the check, with a small burst of fireworks in the row. The
* one is ticked, under the same rule as live (see `Dailies.tsx`): only a tick * tick that finishes the list gets the grand celebration over the panel instead,
* made just now that finishes the list counts, not a list that got shorter. * under the same rule as live (see `Dailies.tsx`): only a tick made just now
* that finishes the list counts, not a list that got shorter.
*/ */
export function DailyPanel({ export function DailyPanel({
rows, rows,
@@ -163,6 +197,16 @@ export function DailyPanel({
).length; ).length;
const allDone = total > 0 && done === total; const allDone = total > 0 && done === total;
const burst = useSectionCelebration(total, done); const burst = useSectionCelebration(total, done);
const finishes = finishesSection(total, done);
// The row whose tick just completed it, for its own small burst.
const [rowBurst, setRowBurst] = useState<{ key: string; n: number } | null>(
null,
);
useEffect(() => {
if (!rowBurst) return;
const id = setTimeout(() => setRowBurst(null), 1400);
return () => clearTimeout(id);
}, [rowBurst]);
// Rows ticked by a click in this panel; only those animate, so a reload // Rows ticked by a click in this panel; only those animate, so a reload
// with everything already done doesn't replay every pop. // with everything already done doesn't replay every pop.
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set()); const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
@@ -231,7 +275,12 @@ export function DailyPanel({
data-done={isDone || undefined} data-done={isDone || undefined}
> >
{label} {label}
<RoutineControl row={row} onAction={onAction} now={now} /> <RoutineControl
row={row}
onAction={onAction}
now={now}
finishes={finishes}
/>
</div> </div>
); );
return ( return (
@@ -250,6 +299,8 @@ export function DailyPanel({
goal ? row.status : undefined, goal ? row.status : undefined,
)} )}
onClick={() => { onClick={() => {
if (!isDone && !finishes)
setRowBurst((b) => ({ key, n: (b?.n ?? 0) + 1 }));
setFresh((f) => { setFresh((f) => {
const n = new Set(f); const n = new Set(f);
if (ticked) n.delete(key); if (ticked) n.delete(key);
@@ -259,6 +310,7 @@ export function DailyPanel({
toggleToday(row, today, onAction); toggleToday(row, today, onAction);
}} }}
> >
{rowBurst?.key === key && <Fireworks key={rowBurst.n} />}
{label} {label}
{/* One counter, as on live: a goal's count answers "is this week {/* One counter, as on live: a goal's count answers "is this week
done?", so a streak beside it is beside the point. */} done?", so a streak beside it is beside the point. */}
@@ -34,25 +34,37 @@ export function PinnedRoutine({
hue, hue,
onAction, onAction,
now, now,
finishes = false,
}: { }: {
row: RoutineRow; row: RoutineRow;
hue: string; hue: string;
onAction: ActionHandler; onAction: ActionHandler;
now: number; now: number;
/** Completing it finishes its group, whose grand celebration goes instead. */
finishes?: boolean;
}) { }) {
const today = readerDayKey(now, row.clock); const today = readerDayKey(now, row.clock);
const amount = row.status.measure === "amount"; const amount = row.status.measure === "amount";
const met = row.status.met; const met = row.status.met;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [burst, setBurst] = useState(0); const [burst, setBurst] = useState(0);
// Whether the burst it is showing finished the group: then the box still
// pops, but the group's grand celebration stands in for the sparks.
const [quiet, setQuiet] = useState(false);
// The amount before a completing action, for undo. // The amount before a completing action, for undo.
const [before, setBefore] = useState<number | null>(null); const [before, setBefore] = useState<number | null>(null);
// Set by an action in this row, so only a completion made here celebrates, // Set by an action in this row, so only a completion made here celebrates,
// never one that arrives with a reload or a new period. // never one that arrives with a reload or a new period.
const acted = useRef(false); const acted = useRef(false);
const wasMet = useRef(met); const wasMet = useRef(met);
// As it stood at the completing action: by the time `met` flips, the group
// already counts this routine done.
const finishesGroup = useRef(false);
useEffect(() => { useEffect(() => {
if (met && !wasMet.current && acted.current) setBurst((n) => n + 1); if (met && !wasMet.current && acted.current) {
setQuiet(finishesGroup.current);
setBurst((n) => n + 1);
}
wasMet.current = met; wasMet.current = met;
acted.current = false; acted.current = false;
}, [met]); }, [met]);
@@ -69,6 +81,7 @@ export function PinnedRoutine({
required = row.status.required; required = row.status.required;
const setAmount = (value: number) => { const setAmount = (value: number) => {
acted.current = true; acted.current = true;
finishesGroup.current = finishes;
void onAction({ void onAction({
type: "amount", type: "amount",
accountId: row.accountId, accountId: row.accountId,
@@ -81,6 +94,7 @@ export function PinnedRoutine({
}; };
const setDay = (day: string, checked: boolean) => { const setDay = (day: string, checked: boolean) => {
acted.current = true; acted.current = true;
finishesGroup.current = finishes;
void onAction({ void onAction({
type: "day", type: "day",
accountId: row.accountId, accountId: row.accountId,
@@ -127,7 +141,7 @@ export function PinnedRoutine({
data-burst={burst > 0 || undefined} data-burst={burst > 0 || undefined}
style={{ "--g": hue } as CSSProperties} style={{ "--g": hue } as CSSProperties}
> >
{burst > 0 && <Fireworks key={burst} />} {burst > 0 && !quiet && <Fireworks key={burst} />}
<div className="pr-text"> <div className="pr-text">
<strong>{row.title}</strong> <strong>{row.title}</strong>
<small> <small>
@@ -1,29 +1,40 @@
import { useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { CelebrationsContext } from "../Fireworks.tsx";
/** How long the grand celebration stays mounted: the last wave's sparks included. */ /** How long the grand celebration stays mounted: the last wave's sparks included. */
export const CELEBRATION_MS = 2600; export const CELEBRATION_MS = 2600;
/**
* Whether completing one more item now finishes the section: that item gets
* the section's grand celebration, not its own small burst on top.
*/
export function finishesSection(total: number, done: number): boolean {
return total > 0 && done === total - 1;
}
/** /**
* Counts up when a section has just been finished: everything in it done, * Counts up when a section has just been finished: everything in it done,
* more done than a moment ago, and the same number of things as before. That * more done than a moment ago, and the same number of things as before. That
* is the live strip's rule (Dailies.tsx): a list that got shorter (focusing a * is the live strip's rule (Dailies.tsx): a list that got shorter (focusing a
* game, unpinning) or a page that opens already complete is not the reader * game, unpinning) or a page that opens already complete is not the reader
* finishing anything, so neither celebrates. * finishing anything, so neither celebrates. With celebrations switched off
* it stays at 0, so the sweep and the "All done" pop go too.
*/ */
export function useSectionCelebration(total: number, done: number): number { export function useSectionCelebration(total: number, done: number): number {
const on = useContext(CelebrationsContext);
const [burst, setBurst] = useState(0); const [burst, setBurst] = useState(0);
const previous = useRef<{ total: number; done: number } | null>(null); const previous = useRef<{ total: number; done: number } | null>(null);
const complete = total > 0 && done === total; const complete = total > 0 && done === total;
useEffect(() => { useEffect(() => {
const was = previous.current; const was = previous.current;
previous.current = { total, done }; previous.current = { total, done };
if (was === null || !complete) return; if (was === null || !complete || !on) return;
if (was.total === total && done > was.done) setBurst((n) => n + 1); if (was.total === total && done > was.done) setBurst((n) => n + 1);
}, [total, done, complete]); }, [total, done, complete, on]);
useEffect(() => { useEffect(() => {
if (burst === 0) return; if (burst === 0) return;
const id = setTimeout(() => setBurst(0), CELEBRATION_MS); const id = setTimeout(() => setBurst(0), CELEBRATION_MS);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [burst]); }, [burst]);
return burst; return on ? burst : 0;
} }
+5 -2
View File
@@ -539,6 +539,7 @@ body {
color: var(--color-ink); color: var(--color-ink);
} }
.routine-control { .routine-control {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -4584,8 +4585,9 @@ button.mark {
/* Today's dailies: the whole row ticks, and a tick should feel like one. /* Today's dailies: the whole row ticks, and a tick should feel like one.
Done rows wash in the game's colour, the box pops and the check draws. Done rows wash in the game's colour, the box pops and the check draws.
Only rows ticked just now (data-fresh) animate; the live fireworks Only rows ticked just now (data-fresh) animate, with the live fireworks
(Fireworks.tsx, styles.css) fire when the last one goes. */ (Fireworks.tsx, styles.css) in the row; the last one gets the grand
celebration over the panel instead. */
.daily-panel { .daily-panel {
position: relative; position: relative;
} }
@@ -4624,6 +4626,7 @@ button.mark {
font-weight: 600; font-weight: 600;
} }
.daily-hit { .daily-hit {
position: relative;
width: calc(100% + 20px); width: calc(100% + 20px);
margin: 0 -10px 4px; margin: 0 -10px 4px;
padding: 10px; padding: 10px;
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
CelebrationsContext,
Fireworks,
celebrationsOn,
} from "../src/client/components/Fireworks.tsx";
import { finishesSection } from "../src/client/components/redesign/useSectionCelebration.ts";
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
test("celebrations are on unless the reader turned them off", () => {
expect(celebrationsOn({})).toBe(true);
expect(celebrationsOn({ celebrations: true })).toBe(true);
expect(celebrationsOn({ celebrations: false })).toBe(false);
});
test("with celebrations off, no fireworks draw, big or small", () => {
for (const grand of [false, true])
expect(
render(
<CelebrationsContext value={false}>
<Fireworks grand={grand} />
</CelebrationsContext>,
),
).toBe("");
expect(render(<Fireworks />)).toContain('class="fireworks"');
});
test("the item that finishes a section gets the big celebration, not the small burst too", () => {
expect(finishesSection(3, 2)).toBe(true);
expect(finishesSection(3, 1)).toBe(false);
expect(finishesSection(1, 0)).toBe(true);
expect(finishesSection(3, 3)).toBe(false);
});
test("Appearance offers a Celebrations switch, on by default", () => {
const s = migrateLegacy(
{},
[],
"2026-10-16T12:00:00.000Z",
() => "account:test",
);
const on = render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
expect(on).toContain("Celebrations");
expect(on).toMatch(
/<input type="checkbox" data-celebrations="true" checked=""/,
);
s.prefs.celebrations = false;
const off = render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
expect(off).not.toMatch(/data-celebrations="true" checked=""/);
});