feat(daily): a small burst when the last daily lands

The one moment in this app that is unambiguously good news, and it read
the same as any other tick.

Fires only on a tick that completes the set — the list also shortens when
the reader filters, and a burst there would be congratulating them for
filtering. Nothing on mount either, so arriving at an already-finished
day is not treated as having just finished it.

Decorative, aria-hidden and under a second; the "All done" text beside it
is what a screen reader gets. Fully off under prefers-reduced-motion
rather than merely reduced — nothing here carries meaning, so the honest
reduced version is nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-16 20:39:09 +02:00
co-authored by Claude Opus 5
parent a0d0faf231
commit c3e9b9064d
4 changed files with 146 additions and 5 deletions
+1
View File
@@ -90,6 +90,7 @@ src/
ProgressControls status, effort, note (F12) ProgressControls status, effort, note (F12)
Dailies.tsx today's strip of repeating jobs Dailies.tsx today's strip of repeating jobs
DailyChecklist one repeating event's whole run DailyChecklist one repeating event's whole run
Fireworks.tsx the burst when the last daily lands
Controls.tsx games, region, export/import(F4, F5, F6) Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8) Welcome.tsx first-run game picker (F8)
Toast.tsx undo an ignore Toast.tsx undo an ignore
+32 -5
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts"; import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
import { gameMeta } from "../../shared/games.ts"; import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts"; import type { GachaEvent, GameId, Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts"; import { formatRemaining } from "../../shared/time.ts";
import { Fireworks } from "./Fireworks.tsx";
/** /**
* The chores no source publishes. * The chores no source publishes.
@@ -55,6 +57,29 @@ export function Dailies({
const items = [...chores, ...repeating]; const items = [...chores, ...repeating];
const total = items.length; const total = items.length;
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
const allDone = total > 0 && complete === total;
const [burst, setBurst] = useState(0);
// Null until the first render has been seen, so arriving at a page where
// everything is already ticked is not treated as having just finished it.
const previous = useRef<{ total: number; complete: number } | null>(null);
useEffect(() => {
const was = previous.current;
previous.current = { total, complete };
if (was === null || !allDone) return;
// Celebrate finishing the last one, and only that. The list also gets
// shorter when the reader marks a repeating event done, which can land on
// "all complete" without them having ticked anything — a burst there is the
// app congratulating them for filtering.
if (was.total === total && complete > was.complete) setBurst((n) => n + 1);
}, [total, complete, allDone]);
useEffect(() => {
if (burst === 0) return;
const id = setTimeout(() => setBurst(0), 1400);
return () => clearTimeout(id);
}, [burst]);
if (total === 0) return null; if (total === 0) return null;
@@ -64,8 +89,10 @@ export function Dailies({
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1; const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
return ( return (
<section className="border-b border-hairline px-4 py-4"> <section className="relative border-b border-hairline px-4 py-4">
<div className="flex items-baseline justify-between gap-3"> {burst > 0 && <Fireworks key={burst} />}
<div className="relative flex items-baseline justify-between gap-3">
<h2 className="eyebrow"> <h2 className="eyebrow">
Today's dailies · {complete}/{total} Today's dailies · {complete}/{total}
</h2> </h2>
@@ -75,7 +102,7 @@ export function Dailies({
</p> </p>
</div> </div>
<ul className="mt-2.5 flex flex-wrap gap-1.5"> <ul className="relative mt-2.5 flex flex-wrap gap-1.5">
{chores.map((chore) => ( {chores.map((chore) => (
<li key={chore.key}> <li key={chore.key}>
<TickChip <TickChip
@@ -105,8 +132,8 @@ export function Dailies({
))} ))}
</ul> </ul>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint"> <p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
{complete === total {allDone
? "All done. Nothing else expires tonight." ? "All done. Nothing else expires tonight."
: `${waiting(total - complete)} still waiting on you today.`} : `${waiting(total - complete)} still waiting on you today.`}
</p> </p>
+61
View File
@@ -0,0 +1,61 @@
/**
* A small burst, for the one moment in this app that is unambiguously good
* news: every daily ticked off before the reset.
*
* Deliberately tiny and deliberately silent — decorative only, `aria-hidden`,
* and gone in under a second. The text beside it already says "All done", which
* is what a screen reader gets; this is the same sentence said in colour.
*
* Fully off under `prefers-reduced-motion` (see styles.css). A celebration
* nobody asked for is exactly the kind of motion that setting exists to stop,
* and the state it celebrates is legible without it.
*/
/**
* 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
* out of the "renders differently on every paint" category.
*/
const BURSTS: Array<{ x: number; y: number; delay: number }> = [
{ x: 18, y: 40, delay: 0 },
{ x: 52, y: 22, delay: 140 },
{ x: 82, y: 52, delay: 260 },
];
const SPARKS = 9;
/** The heat ramp, reused: the palette already means "this app", so it stays. */
const COLORS = [
"var(--color-near)",
"var(--color-soon)",
"var(--color-critical)",
"var(--color-ink)",
];
export function Fireworks() {
return (
<div aria-hidden className="fireworks">
{BURSTS.map((burst, b) => (
<span
key={b}
className="firework"
style={{ left: `${burst.x}%`, top: `${burst.y}%` }}
>
{Array.from({ length: SPARKS }, (_, i) => (
<span
key={i}
className="spark"
style={{
["--a" as string]: `${(360 / SPARKS) * i + b * 13}deg`,
// Uneven reach, so it reads as a burst rather than a wheel.
["--d" as string]: `${16 + ((i * 7) % 13)}px`,
["--delay" as string]: `${burst.delay}ms`,
background: COLORS[(i + b) % COLORS.length] ?? "var(--color-ink)",
}}
/>
))}
</span>
))}
</div>
);
}
+52
View File
@@ -225,6 +225,58 @@ body {
} }
} }
/* ---- The daily burst ---------------------------------------------------- */
/*
* Fires once, when the last daily of the day gets ticked. Purely decorative and
* pointer-transparent, so it never sits between the reader and a chip.
*
* The whole thing is inside `prefers-reduced-motion: no-preference` rather than
* being merely reduced by it: unlike the meter, nothing here carries meaning
* that would be lost, so the honest reduced version is nothing at all.
*/
.fireworks {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
display: none;
}
.firework {
position: absolute;
display: block;
width: 0;
height: 0;
}
.spark {
position: absolute;
width: 3px;
height: 3px;
border-radius: 999px;
}
@media (prefers-reduced-motion: no-preference) {
.fireworks {
display: block;
}
.spark {
animation: spark-fly 900ms cubic-bezier(0.15, 0.7, 0.35, 1) var(--delay, 0ms)
both;
}
@keyframes spark-fly {
from {
transform: rotate(var(--a)) translateX(0) scale(0.6);
opacity: 0;
}
18% {
opacity: 1;
}
to {
transform: rotate(var(--a)) translateX(var(--d)) scale(1);
opacity: 0;
}
}
}
/* Focus ring: visible, and never removed. */ /* Focus ring: visible, and never removed. */
:where(a, button, [tabindex]):focus-visible { :where(a, button, [tabindex]):focus-visible {
outline: 2px solid var(--color-near); outline: 2px solid var(--color-near);