Files
gacha-event-tracker/src/client/components/Dailies.tsx
T
Lucas WintherandClaude Opus 5 a0d0faf231 feat(daily): keep each game's colour on its chip, done or not
The hue only appeared on completion, so the half of the strip a reader
actually scans — the unfinished half — was a row of identical grey pills
with nothing to say which game each belonged to.

Soft border and wash while outstanding, full strength once ticked. Done
is still legible without the colour: the tick, the stronger border and
the deeper wash all carry it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:38:14 +02:00

190 lines
6.3 KiB
TypeScript

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";
/**
* The chores no source publishes.
*
* Commissions, sanity, daily training — the routine that runs whether or not
* an event is on. They are the most-missed thing in every one of these games
* and they appear on no wiki page, so they are a fixed client-side list rather
* than feed data. Ticking one is stored in exactly the same day log an event's
* checklist uses, so streaks and exports work the same way for both.
*
* Running events that repeat sit here too, so ticking today off never means
* opening a sheet to find the checklist. The checklist is still where the whole
* run lives — this is just today's line of it.
*
* Sits above the event list because it is the one part of the page that is
* answerable in ten seconds and expires tonight.
*/
export function Dailies({
games,
events,
region,
now,
daysFor,
onToggleDay,
}: {
games: GameId[];
/** Live events that repeat daily — detected, or marked by the reader. */
events: GachaEvent[];
region: Region;
now: number;
daysFor: (id: string) => string[];
onToggleDay: (id: string, day: string) => void;
}) {
// Each game rolls on its own server clock, so "today" is asked per game
// rather than once for the section — Endfield's European day can still be
// yesterday's while every HoYo game has already turned over.
const chores = games.map((id) => ({
key: dailiesId(id),
game: gameMeta(id),
today: dayKey(now, region, id),
resetsIn: msUntilReset(now, region, id),
}));
const repeating = events.map((event) => ({
key: event.id,
event,
game: gameMeta(event.game),
today: dayKey(now, region, event.game),
resetsIn: msUntilReset(now, region, event.game),
}));
const items = [...chores, ...repeating];
const total = items.length;
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
if (total === 0) return null;
// With mixed reset clocks there is no single "resets in", so the header
// reports the next one to land and says that it is the next one.
const soonest = Math.min(...items.map((i) => i.resetsIn));
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">
<h2 className="eyebrow">
Today's dailies · {complete}/{total}
</h2>
<p className="tnum text-[0.6875rem] text-faint">
{mixed ? "next reset in " : "resets in "}
{formatRemaining(soonest)}
</p>
</div>
<ul className="mt-2.5 flex flex-wrap gap-1.5">
{chores.map((chore) => (
<li key={chore.key}>
<TickChip
label={chore.game.short}
hue={chore.game.hue}
title={chore.game.dailyTasks}
ariaLabel={`${chore.game.name} dailies — ${chore.game.dailyTasks}`}
days={daysFor(chore.key)}
today={chore.today}
onToggle={() => onToggleDay(chore.key, chore.today)}
/>
</li>
))}
{repeating.map((row) => (
<li key={row.key}>
<TickChip
label={row.event.title}
hue={row.game.hue}
title={`${row.game.name} — ${row.event.title}`}
ariaLabel={`${row.event.title} (${row.game.name})`}
days={daysFor(row.key)}
today={row.today}
onToggle={() => onToggleDay(row.key, row.today)}
/>
</li>
))}
</ul>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
{complete === total
? "All done. Nothing else expires tonight."
: `${waiting(total - complete)} still waiting on you today.`}
</p>
</section>
);
}
/**
* One thing to tick off today.
*
* The same pill whether it is a game's standing chore or an event that repeats:
* to the reader at 23:50 they are the same job, and the distinction between
* "the app knows about this" and "a wiki published it" is ours, not theirs.
*
* The game's hue is on the border in both states softly when the job is
* outstanding, fully once it is done. A chip that only takes its colour on
* completion means the strip you actually scan, the unfinished one, is a row of
* identical grey pills with no clue which game each belongs to.
*/
function TickChip({
label,
hue,
title,
ariaLabel,
days,
today,
onToggle,
}: {
label: string;
hue: string;
title: string;
ariaLabel: string;
days: string[];
today: string;
onToggle: () => void;
}) {
const isDone = days.includes(today);
const streak = streakOf(days, today);
return (
<button
type="button"
onClick={onToggle}
aria-pressed={isDone}
aria-label={`${ariaLabel}${isDone ? ", done today" : ", not done today"}`}
title={title}
className="flex max-w-[15rem] items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: isDone ? hue : `color-mix(in srgb, ${hue} 34%, transparent)`,
// Colour identifies the game; done-ness is carried by the tick, the
// full-strength border and the wash. Keeping the label readable matters
// more than saturating it, so an outstanding chip stays on muted ink.
color: isDone ? hue : "var(--color-muted)",
background: isDone
? `color-mix(in srgb, ${hue} 14%, transparent)`
: `color-mix(in srgb, ${hue} 5%, transparent)`,
}}
>
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
<path
d="M2.5 8.5l3.5 3.5 7.5-8"
fill="none"
stroke={isDone ? "currentColor" : hue}
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
opacity={isDone ? 1 : 0.35}
/>
</svg>
<span className="truncate">{label}</span>
{streak > 1 && (
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{streak}d</span>
)}
</button>
);
}
function waiting(n: number): string {
return n === 1 ? "One thing" : `${n} things`;
}