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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-18 22:46:46 +02:00
co-authored by Claude Opus 5.5
parent 185ccb2507
commit f17b7985ef
11 changed files with 734 additions and 527 deletions
+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`.
`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`)
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.
+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.
### Daily burst
A small spray of 3px sparks that fires once, when the last daily of the day is ticked. It is purely
decorative and ignores pointer input. With reduced motion requested it does not render at all,
rather than being toned down.
A small spray of 3px sparks that fires once, in the row, when a reader finishes one thing: a daily
ticked, a routine met, or an event marked completed (in its "Moved to Archive" notice). Finishing a
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
@@ -616,11 +618,16 @@ legend on the same baseline as "Next: …".
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:
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
section (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.
- **Celebration.** A single completion fires live's small `Fireworks` in the row: a daily ticked in
the panel, a pinned routine met, an amount typed up to its target. Finishing a whole section
(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
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
+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
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
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 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
* news: every daily ticked off before the reset.
* A small burst, for finishing something: a daily ticked, a routine met, an
* event marked completed. Finishing a whole section gets the grand version.
*
* Deliberately tiny and deliberately silent — decorative only, `aria-hidden`,
* 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.
*/
/**
* 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,
* 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. */
hue?: string | undefined;
} = {}) {
const on = useContext(CelebrationsContext);
if (!on) return null;
const bursts = grand ? GRAND_BURSTS : BURSTS,
sparks = grand ? GRAND_SPARKS : SPARKS,
colors = hue ? [hue, ...COLORS] : COLORS;
@@ -9,6 +9,7 @@ import { timelineLook } from "./TimelinePage.tsx";
import { ViewCards } from "./ViewCards.tsx";
import { focusBarShown } from "./FocusPicker.tsx";
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
import { celebrationsOn } from "../Fireworks.tsx";
/** The live legend's words for each step. */
const URGENCY_LABEL = {
@@ -353,6 +354,22 @@ export function AppearancePage({
</label>
))}
</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">
<h2>Checklist</h2>
<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 { chipLabel } from "../Dailies.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 { RoutineRow } from "../../state/reader/routines.ts";
@@ -18,19 +21,24 @@ export function RoutineControl({
row,
onAction,
now,
finishes = false,
}: {
row: RoutineRow;
onAction: ActionHandler;
now: number;
/** Completing it finishes the section, whose grand celebration goes instead. */
finishes?: boolean;
}) {
const today = readerDayKey(now, row.clock);
const [amount, setAmount] = useState(String(row.status.count));
const editing = useRef(false);
const [burst, celebrate] = useBurst();
useEffect(() => {
if (!editing.current) setAmount(String(row.status.count));
}, [row.status.count, row.status.periodStart]);
return (
<div className="routine-control">
{burst > 0 && <Fireworks key={burst} />}
<div>
<strong>{row.title}</strong>
<small>
@@ -58,16 +66,22 @@ export function RoutineControl({
onChange={(e) => {
setAmount(e.target.value);
const value = e.target.valueAsNumber;
if (Number.isInteger(value) && value >= 0)
void onAction({
type: "amount",
accountId: row.accountId,
revisionId: row.clock.revision,
itemId: row.id,
period: row.status.periodStart,
value,
...actionMeta(),
});
if (!Number.isInteger(value) || value < 0) return;
if (
!finishes &&
row.status.count < row.status.required &&
value >= row.status.required
)
celebrate();
void onAction({
type: "amount",
accountId: row.accountId,
revisionId: row.clock.revision,
itemId: row.id,
period: row.status.periodStart,
value,
...actionMeta(),
});
}}
/>
</label>
@@ -77,7 +91,11 @@ export function RoutineControl({
className="daily-tick"
aria-label={`${row.title}, ${row.days.includes(today) ? "done" : "not done"} 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">
<rect
@@ -103,6 +121,21 @@ export function RoutineControl({
</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. */
function toggleToday(row: RoutineRow, today: string, onAction: ActionHandler) {
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
* 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
* one is ticked, 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.
* the box and draws the check, with a small burst of fireworks in the row. The
* tick that finishes the list gets the grand celebration over the panel instead,
* 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({
rows,
@@ -163,6 +197,16 @@ export function DailyPanel({
).length;
const allDone = total > 0 && done === total;
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
// with everything already done doesn't replay every pop.
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
@@ -231,7 +275,12 @@ export function DailyPanel({
data-done={isDone || undefined}
>
{label}
<RoutineControl row={row} onAction={onAction} now={now} />
<RoutineControl
row={row}
onAction={onAction}
now={now}
finishes={finishes}
/>
</div>
);
return (
@@ -250,6 +299,8 @@ export function DailyPanel({
goal ? row.status : undefined,
)}
onClick={() => {
if (!isDone && !finishes)
setRowBurst((b) => ({ key, n: (b?.n ?? 0) + 1 }));
setFresh((f) => {
const n = new Set(f);
if (ticked) n.delete(key);
@@ -259,6 +310,7 @@ export function DailyPanel({
toggleToday(row, today, onAction);
}}
>
{rowBurst?.key === key && <Fireworks key={rowBurst.n} />}
{label}
{/* One counter, as on live: a goal's count answers "is this week
done?", so a streak beside it is beside the point. */}
@@ -34,25 +34,37 @@ export function PinnedRoutine({
hue,
onAction,
now,
finishes = false,
}: {
row: RoutineRow;
hue: string;
onAction: ActionHandler;
now: number;
/** Completing it finishes its group, whose grand celebration goes instead. */
finishes?: boolean;
}) {
const today = readerDayKey(now, row.clock);
const amount = row.status.measure === "amount";
const met = row.status.met;
const [editing, setEditing] = useState(false);
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.
const [before, setBefore] = useState<number | null>(null);
// 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.
const acted = useRef(false);
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(() => {
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;
acted.current = false;
}, [met]);
@@ -69,6 +81,7 @@ export function PinnedRoutine({
required = row.status.required;
const setAmount = (value: number) => {
acted.current = true;
finishesGroup.current = finishes;
void onAction({
type: "amount",
accountId: row.accountId,
@@ -81,6 +94,7 @@ export function PinnedRoutine({
};
const setDay = (day: string, checked: boolean) => {
acted.current = true;
finishesGroup.current = finishes;
void onAction({
type: "day",
accountId: row.accountId,
@@ -127,7 +141,7 @@ export function PinnedRoutine({
data-burst={burst > 0 || undefined}
style={{ "--g": hue } as CSSProperties}
>
{burst > 0 && <Fireworks key={burst} />}
{burst > 0 && !quiet && <Fireworks key={burst} />}
<div className="pr-text">
<strong>{row.title}</strong>
<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. */
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,
* 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
* 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 {
const on = useContext(CelebrationsContext);
const [burst, setBurst] = useState(0);
const previous = useRef<{ total: number; done: number } | null>(null);
const complete = total > 0 && done === total;
useEffect(() => {
const was = previous.current;
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);
}, [total, done, complete]);
}, [total, done, complete, on]);
useEffect(() => {
if (burst === 0) return;
const id = setTimeout(() => setBurst(0), CELEBRATION_MS);
return () => clearTimeout(id);
}, [burst]);
return burst;
return on ? burst : 0;
}
+5 -2
View File
@@ -539,6 +539,7 @@ body {
color: var(--color-ink);
}
.routine-control {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@@ -4584,8 +4585,9 @@ button.mark {
/* 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.
Only rows ticked just now (data-fresh) animate; the live fireworks
(Fireworks.tsx, styles.css) fire when the last one goes. */
Only rows ticked just now (data-fresh) animate, with the live fireworks
(Fireworks.tsx, styles.css) in the row; the last one gets the grand
celebration over the panel instead. */
.daily-panel {
position: relative;
}
@@ -4624,6 +4626,7 @@ button.mark {
font-weight: 600;
}
.daily-hit {
position: relative;
width: calc(100% + 20px);
margin: 0 -10px 4px;
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=""/);
});