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:
co-authored by
Claude Opus 5.5
parent
185ccb2507
commit
f17b7985ef
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=""/);
|
||||
});
|
||||
Reference in New Issue
Block a user