diff --git a/src/client/App.tsx b/src/client/App.tsx index e327e69..e610ccd 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -31,6 +31,7 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; +import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; import { strandedOccurrences } from "../shared/recurrence.ts"; import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; @@ -152,17 +153,37 @@ export function App() { return daily.daysFor(id).length > 0 ? "doing" : "idle"; }; + /** The reader's check-in goal for an event, if they set a readable one. */ + const goalOf = (id: string) => readGoal(prog.progress[id]?.goal); + /** * Whether an event repeats, the reader's own answer included. Detection reads - * the source's wording; they can overrule it either way. + * the source's wording; they can overrule it either way. A goal settles it + * outright: the reader has told us they come back to this one. */ const repeatsDaily = (row: RowEvent): boolean => + goalOf(row.event.id) !== undefined || resolveDaily( row.event, prog.progress[row.event.id]?.daily, prefs.detectDaily, ); + /** Where the reader is against an event's goal, or undefined without one. */ + const goalStatusOf = (row: RowEvent): GoalStatus | undefined => { + const goal = goalOf(row.event.id); + if (goal === undefined) return undefined; + return goalStatus({ + goal, + startsMs: row.clock.startsMs, + endsMs: row.clock.endsMs, + region: prefs.region, + game: row.event.game, + now, + logged: daily.daysFor(row.event.id), + }); + }; + /** Today's state for a repeating event, or undefined if it does not repeat. */ const dailyBadge = (row: RowEvent): DailyBadge | undefined => { if (!repeatsDaily(row)) return undefined; @@ -546,6 +567,10 @@ export function App() { now={now} daysFor={daily.daysFor} onToggleDay={daily.toggleDay} + goalFor={(id) => { + const row = todo.find((r) => r.event.id === id); + return row === undefined ? undefined : goalStatusOf(row); + }} /> diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 8d0077e..5dbd240 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -11,6 +11,7 @@ import { useGameMeta } from "../state/gameMeta.tsx"; import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/custom.ts"; import type { GameMeta } from "../../shared/games.ts"; import type { GameId, Region } from "../../shared/schema.ts"; +import { goalCount, goalLabel, type GoalStatus } from "../../shared/goal.ts"; import { formatRemaining } from "../../shared/time.ts"; import { DayPip } from "./DayPip.tsx"; import { Fireworks } from "./Fireworks.tsx"; @@ -47,6 +48,8 @@ export interface DailyItem { * which has no start because it is a routine rather than an event. */ notBefore: number | null; + /** The event's check-in goal, when the reader set one. Chores have none. */ + goal?: GoalStatus | undefined; } /** A game and everything of its that wants ticking today. */ @@ -86,6 +89,7 @@ export function dailyGroups( meta: (id: LaneId) => GameMeta, startOf: (event: DisplayEvent) => number, showChores = true, + goalFor: (id: string) => GoalStatus | undefined = () => undefined, ): DailyGroup[] { // A lane can arrive through an event without being in `games` — an event on a // game the reader has since switched off, say — and dropping it here would @@ -133,6 +137,7 @@ export function dailyGroups( today, resetsIn, notBefore: startOf(event), + goal: goalFor(event.id), }); } @@ -141,6 +146,36 @@ export function dailyGroups( return groups; } +/** + * How much of the strip is finished. An item is complete when ticked today or + * when its goal is already met — nothing is ticked on its behalf. `tickedToday` + * is kept apart because only a real tick celebrates. + */ +export function dailyTally( + items: readonly DailyItem[], + daysFor: (id: string) => string[], +): { total: number; complete: number; tickedToday: number } { + let complete = 0; + let tickedToday = 0; + for (const i of items) { + const ticked = daysFor(i.key).includes(i.today); + if (ticked) tickedToday += 1; + if (ticked || i.goal?.met === true) complete += 1; + } + return { total: items.length, complete, tickedToday }; +} + +/** The chip's accessible name: today's tick and the goal are separate facts. */ +export function chipLabel( + base: string, + isDone: boolean, + goal: GoalStatus | undefined, +): string { + if (!isDone && goal?.met === true) return `${base}, ${goalLabel(goal)}`; + const today = isDone ? "done today" : "not done today"; + return goal === undefined ? `${base}, ${today}` : `${base}, ${today}, ${goalLabel(goal)}`; +} + export function Dailies({ games, events, @@ -149,6 +184,7 @@ export function Dailies({ daysFor, onToggleDay, showChores, + goalFor, }: { /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; @@ -164,6 +200,11 @@ export function Dailies({ now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; + /** + * An event's check-in goal status, when the reader set one. A met goal + * completes its item for the header without ticking anything. + */ + goalFor?: ((id: string) => GoalStatus | undefined) | undefined; }) { const gameMeta = useGameMeta(); /** @@ -187,28 +228,30 @@ export function Dailies({ gameMeta, (event) => Date.parse(event.startsAt), showChores, + goalFor, ); const items = groups.flatMap((group) => group.items); - const total = items.length; - const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; + const { total, complete, tickedToday } = dailyTally(items, daysFor); 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); + const previous = useRef<{ total: number; tickedToday: number } | null>(null); useEffect(() => { const was = previous.current; - previous.current = { total, complete }; + previous.current = { total, tickedToday }; if (was === null || !allDone) return; - // Celebrate finishing the last one, and only that. The list also gets - // shorter when the reader focuses a single game or 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. - // Backfilling a past day moves no count here, so it never bursts. - if (was.total === total && complete > was.complete) setBurst((n) => n + 1); - }, [total, complete, allDone]); + // Celebrate a tick today that finished the strip, and only that. The list + // also gets shorter when the reader focuses a single game or 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. A backfilled day can complete the strip too, by meeting a + // week's goal, and that is not something they just did either; so the + // count watched is today's ticks, not completion. + if (was.total === total && tickedToday > was.tickedToday) setBurst((n) => n + 1); + }, [total, tickedToday, allDone]); useEffect(() => { if (burst === 0) return; @@ -261,6 +304,7 @@ export function Dailies({ ariaLabel={item.ariaLabel} days={daysFor(item.key)} today={item.today} + goal={item.goal} onToggle={() => onToggleDay(item.key, item.today)} /> @@ -369,6 +413,11 @@ export function CatchUpPanel({ * state: it has to say *which game* without competing with the tick, which is * the only thing on the chip that answers the question the reader came with. * Ticking one should be a visible jump, not a nudge. + * + * An event with a check-in goal has a third state: nothing ticked today, but + * the week's count already met. It takes the done border and a full-strength + * check, drawn dashed, and keeps the outstanding ground — only a real tick + * earns the wash, so "done this week" never reads as "done today". */ function TickChip({ label, @@ -377,6 +426,7 @@ function TickChip({ ariaLabel, days, today, + goal, onToggle, }: { label: string; @@ -385,9 +435,11 @@ function TickChip({ ariaLabel: string; days: string[]; today: string; + goal?: GoalStatus | undefined; onToggle: () => void; }) { const isDone = days.includes(today); + const met = !isDone && goal?.met === true; const streak = streakOf(days, today); return ( @@ -395,7 +447,7 @@ function TickChip({ type="button" onClick={onToggle} aria-pressed={isDone} - aria-label={`${ariaLabel}${isDone ? ", done today" : ", not done today"}`} + aria-label={chipLabel(ariaLabel, isDone, goal)} title={title} // `.hue-chip` is the shared hover: the chip's own colours are the game's // hue and arrive inline, which no rule can override, so it is drawn from @@ -404,11 +456,11 @@ function TickChip({ className="hue-chip flex max-w-[15rem] items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium" style={{ ["--hue" as string]: hue, - borderColor: isDone ? hue : `color-mix(in srgb, ${hue} 20%, transparent)`, + borderColor: isDone || met ? hue : `color-mix(in srgb, ${hue} 20%, 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)", + color: isDone || met ? hue : "var(--color-muted)", background: isDone ? `color-mix(in srgb, ${hue} 14%, transparent)` : `color-mix(in srgb, ${hue} 3%, transparent)`, @@ -418,16 +470,24 @@ function TickChip({ {label} - {streak > 1 && ( - {streak}d + {/* One counter per pill. With a goal, its count is the one that answers + "is this week done?"; a streak is beside the point when every day was + never required. */} + {goal !== undefined ? ( + {goalCount(goal)} + ) : ( + streak > 1 && ( + {streak}d + ) )} ); diff --git a/test/views.test.tsx b/test/views.test.tsx index a9ed28f..0d5f3a5 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -8,7 +8,14 @@ import { startMarkers, Timeline, } from "../src/client/components/Timeline.tsx"; -import { CatchUpPanel, Dailies, dailyGroups } from "../src/client/components/Dailies.tsx"; +import { + CatchUpPanel, + chipLabel, + Dailies, + dailyGroups, + dailyTally, +} from "../src/client/components/Dailies.tsx"; +import type { GoalStatus } from "../src/shared/goal.ts"; import { Welcome } from "../src/client/components/Welcome.tsx"; import { timelineLanes } from "../src/client/state/lanes.ts"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; @@ -1139,3 +1146,83 @@ describe("the chores toggle reaches the screen", () => { expect(html).toContain("0/1"); }); }); + +describe("goals in the dailies strip", () => { + // A goal lets an event want five days of seven. A met week completes the + // item without a tick being invented, and the chip says which it is. + const NOW = Date.parse("2026-10-15T12:00:00.000Z"); + const meta = (id: string) => metaFor(id, {}); + const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt); + const ev = { + id: "g1", + game: "genshin", + title: "To Temper Thyself", + type: "other", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-11-02T00:00:00.000Z", + endPrecision: "day", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test", + } as never; + const status = (over: Partial = {}): GoalStatus => ({ + scope: "week", required: 5, count: 3, met: false, needed: 2, + open: 4, shortfall: false, ...over, + }); + const strip = (goal: GoalStatus) => + render( + []} + onToggleDay={() => {}} + goalFor={() => goal} + />, + ); + + test("an event's item carries its goal status", () => { + const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, (id) => + id === "g1" ? status() : undefined, + ); + expect(group!.items[0]!.goal).toEqual(status()); + }); + + test("a met goal completes the item without counting as a tick", () => { + const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, () => + status({ count: 5, met: true, needed: 0 }), + ); + expect(dailyTally(group!.items, () => [])).toEqual({ + total: 1, + complete: 1, + tickedToday: 0, + }); + }); + + test("labels separate done today, done this week and goal reached", () => { + expect(chipLabel("X", true, status())).toBe("X, done today, 3 of 5 this week"); + expect(chipLabel("X", false, status())).toBe("X, not done today, 3 of 5 this week"); + expect(chipLabel("X", false, status({ met: true }))).toBe("X, done this week"); + expect(chipLabel("X", false, status({ scope: "event", met: true }))).toBe( + "X, goal reached", + ); + expect(chipLabel("X", true, status({ met: true }))).toBe("X, done today, done this week"); + expect(chipLabel("X", false, undefined)).toBe("X, not done today"); + }); + + test("the chip shows the count, and an unmet week is still waiting", () => { + const html = strip(status()); + expect(html).toContain("3/5 wk"); + expect(html).toMatch(/dailies · ()?0()?\/()?1/); + }); + + test("a met week counts as complete in the header", () => { + expect(strip(status({ count: 5, met: true, needed: 0 }))).toMatch( + /dailies · ()?1()?\/()?1/, + ); + }); +});