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)
Dailies.tsx today's strip of repeating jobs
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)
Welcome.tsx first-run game picker (F8)
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 { gameMeta } from "../../shared/games.ts";
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts";
import { Fireworks } from "./Fireworks.tsx";
/**
* The chores no source publishes.
@@ -55,6 +57,29 @@ export function Dailies({
const items = [...chores, ...repeating];
const total = items.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;
@@ -64,8 +89,10 @@ export function Dailies({
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
return (
<section className="border-b border-hairline px-4 py-4">
<div className="flex items-baseline justify-between gap-3">
<section className="relative border-b border-hairline px-4 py-4">
{burst > 0 && <Fireworks key={burst} />}
<div className="relative flex items-baseline justify-between gap-3">
<h2 className="eyebrow">
Today's dailies · {complete}/{total}
</h2>
@@ -75,7 +102,7 @@ export function Dailies({
</p>
</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) => (
<li key={chore.key}>
<TickChip
@@ -105,8 +132,8 @@ export function Dailies({
))}
</ul>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
{complete === total
<p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
{allDone
? "All done. Nothing else expires tonight."
: `${waiting(total - complete)} still waiting on you today.`}
</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. */
:where(a, button, [tabindex]):focus-visible {
outline: 2px solid var(--color-near);