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:
1 parent
185ccb2507
commit
f17b7985ef
11 files changed
+734
-527
No files matched your search
@@ -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
@@ -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
@@ -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
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
|
* 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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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