diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 30614c4..2b4f3ad 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -12,7 +12,7 @@ import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/cus 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 { addDays, periodOf, type Period } from "../../shared/period.ts"; +import { addDays, cadenceOf, periodOf, type Period } from "../../shared/period.ts"; import type { TaskShape } from "../../shared/task.ts"; import { asOfLabel } from "../../shared/routines.ts"; import { AmountEditor } from "./AmountEditor.tsx"; @@ -206,8 +206,9 @@ export function dailyGroups( ...(clock === undefined ? {} : { clock }), notBefore: null, goal: taskStatusFor(task), - cadence: task.period.unit, - period: task.period, + cadence: cadenceOf(task.period), + // A version has no period before it to catch up on (spec 2026-10-22). + period: task.period.unit === "version" ? undefined : task.period, unit: task.measure.kind === "amount" ? task.measure.unit : undefined, }); } @@ -228,7 +229,7 @@ export function tasksShown>( ): T[] { const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const; // A reader's own daily sits with the standing dailies, not these strips. - return tasks.filter((t) => t.period.unit !== "day" && on[t.period.unit]); + return tasks.filter((t) => t.period.unit !== "day" && on[cadenceOf(t.period) as keyof typeof on]); } /** diff --git a/src/client/components/redesign/RoutineDrawer.tsx b/src/client/components/redesign/RoutineDrawer.tsx index 803e176..c84dd8f 100644 --- a/src/client/components/redesign/RoutineDrawer.tsx +++ b/src/client/components/redesign/RoutineDrawer.tsx @@ -23,6 +23,9 @@ import { } from "../../../shared/routines.ts"; import { asOfLabel, TaskSchema, type Task } from "../../../shared/task.ts"; import { periodOf, type Period } from "../../../shared/period.ts"; +import { versionCaption, versionWindow } from "../../../shared/versions.ts"; + +const capitalise = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); import { AmountTarget } from "../../../shared/goal.ts"; import { DAY, @@ -376,13 +379,21 @@ function Library({ const rows = rowsOf(id), first = rows[0]!, def = builtins.find((b) => b.id === id); + const version = + def?.period.unit === "version" + ? versionWindow(def.game, now) + : undefined; const line = def ? [ - scheduleWords( - def.period, - first.clock.resetHour, - first.clock.resetMinute, - ), + def.period.unit === "version" + ? version + ? capitalise(versionCaption(version, now)) + : "Resets with the version" + : scheduleWords( + def.period, + first.clock.resetHour, + first.clock.resetMinute, + ), measureWords(def), `${sourceLabel(def.source)}, checked ${asOfLabel(def.checked)}`, ] diff --git a/src/client/components/redesign/TimelinePage.tsx b/src/client/components/redesign/TimelinePage.tsx index 95ca50e..4352102 100644 --- a/src/client/components/redesign/TimelinePage.tsx +++ b/src/client/components/redesign/TimelinePage.tsx @@ -22,6 +22,7 @@ import { actionMeta } from "../../state/reader/actions.ts"; import { useGameMeta } from "../../state/gameMeta.tsx"; import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts"; import { periodOf } from "../../../shared/period.ts"; +import { versionWindow } from "../../../shared/versions.ts"; import { routinesFor } from "../../../shared/routines.ts"; import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts"; import { orderGames } from "../../state/gameOrder.ts"; @@ -47,7 +48,12 @@ export const onTimeline = (r: RoutineRow) => export function routinePeriod(row: RoutineRow, now: number) { const key = readerDayKey(now, row.clock), task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id); - const period = task + if (task?.period.unit === "version") { + // Drawn to the recorded end, else to the estimate: a picture, not a reset. + const w = versionWindow(row.game, now); + if (w) return { start: w.start, end: w.end ?? w.estimate ?? now + DAY }; + } + const period = task && task.period.unit !== "version" ? periodOf(task.period, key) : { start: key, days: [key] }; const shift = resetShiftMs(row.clock.region, row.game, row.clock); diff --git a/src/client/state/reader/routines.ts b/src/client/state/reader/routines.ts index c130944..c31edfd 100644 --- a/src/client/state/reader/routines.ts +++ b/src/client/state/reader/routines.ts @@ -7,6 +7,7 @@ import { metaFor } from "../../../shared/games.ts"; import { routinesFor, offered } from "../../../shared/routines.ts"; import { type Task, TaskSchema, taskStatus } from "../../../shared/task.ts"; import { readableAmounts } from "../useAmounts.ts"; +import { cadenceOf } from "../../../shared/period.ts"; import { resolveReaderClock, readerDayKey, @@ -121,7 +122,7 @@ export function routineRows( accountId, game: account.game, title: task.title, - cadence: task.period.unit, + cadence: cadenceOf(task.period), status: taskStatus({ task, region: account.region, diff --git a/src/shared/goal.ts b/src/shared/goal.ts index 80442fd..738d7c8 100644 --- a/src/shared/goal.ts +++ b/src/shared/goal.ts @@ -88,7 +88,7 @@ export interface GoalStatus { /** Fewer days left than check-ins needed. The target is never lowered. */ shortfall: boolean; /** A task's days left in its period from today, ticked or not — what a cycle's chip shows. */ - daysLeft?: number | undefined; + daysLeft?: number | null | undefined; } /** Bounds a walk over keys when the end is unknown; matches daily.ts. */ @@ -213,7 +213,11 @@ export function goalSummary(goal: Goal | undefined): string { /** The Dailies chip's short count. */ export function goalCount(s: GoalStatus): string { // "cy" means nothing; the days left in a cycle say where it is. - if (s.scope === "cycle") return `${num(s.count)}/${num(s.required)} · ${s.daysLeft ?? s.open ?? 0}d`; + if (s.scope === "cycle") { + // A version with no recorded end has no count of days to show. + if (s.daysLeft === null && s.open === null) return `${num(s.count)}/${num(s.required)}`; + return `${num(s.count)}/${num(s.required)} · ${s.daysLeft ?? s.open ?? 0}d`; + } const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : ""; return `${num(s.count)}/${num(s.required)}${unit}`; } diff --git a/src/shared/period.ts b/src/shared/period.ts index bcc16af..a62c282 100644 --- a/src/shared/period.ts +++ b/src/shared/period.ts @@ -56,6 +56,10 @@ export const RoutinePeriodSchema = z.union([ ]); export type RoutinePeriod = z.infer; +/** Where a period is grouped and tallied: a version shows with the cycles. */ +export const cadenceOf = (period: RoutinePeriod): Period["unit"] => + period.unit === "version" ? "cycle" : period.unit; + const msOf = (key: string) => Date.parse(`${key}T00:00:00Z`); const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10); diff --git a/src/shared/routines.ts b/src/shared/routines.ts index 6312083..b18e41b 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { PeriodSchema } from "./period.ts"; +import { RoutinePeriodSchema } from "./period.ts"; import { GameId, Region } from "./schema.ts"; import { shownFor, type PerGame } from "./perGame.ts"; import { MeasureSchema, type TaskShape } from "./task.ts"; @@ -30,7 +30,8 @@ export const RoutineSchema = z.object({ id: z.string().regex(/^routine:[a-z0-9]+:[a-z0-9-]+$/), game: GameId, title: z.string().min(1).max(80), - period: PeriodSchema, + /** A built-in may also reset with its game's version (`versions.ts`). */ + period: RoutinePeriodSchema, measure: MeasureSchema, /** The page that states it. */ source: z.string().url().startsWith("https://"), diff --git a/src/shared/task.ts b/src/shared/task.ts index 526b7cc..db8bf7e 100644 --- a/src/shared/task.ts +++ b/src/shared/task.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import type { LaneId } from "./custom.ts"; import { dayKey } from "./daily.ts"; import { AmountTarget, type AmountLog, type GoalStatus } from "./goal.ts"; -import { periodOf, PeriodSchema } from "./period.ts"; +import { periodOf, PeriodSchema, type RoutinePeriod } from "./period.ts"; +import { versionCaption, versionWindow } from "./versions.ts"; +import { DAY } from "./time.ts"; import type { Region } from "./schema.ts"; /** @@ -79,12 +81,12 @@ export type Task = z.infer; * What the strip and `taskStatus` need from a task — a reader's own, or a * built-in routine (routines.ts), which shares this shape under its own id. */ -export type TaskShape = Pick; +export type TaskShape = Pick & { period: RoutinePeriod }; /** Where the reader is with a task this period. Pure; `now` explicit. */ export function taskStatus(input: { /** `clock`: a built-in's server, when it rolls on one server's reset for everyone. */ - task: Pick & { clock?: Region | undefined }; + task: Pick & { period: RoutinePeriod; clock?: Region | undefined }; region: Region; now: number; logged: readonly string[]; @@ -92,6 +94,7 @@ export function taskStatus(input: { timing?: ResetTiming; }): GoalStatus { const { task, region, now, logged, amounts } = input; + if (task.period.unit === "version") return versionStatus(task.game, task.measure, now, amounts); const today = dayKey(now, task.clock ?? region, task.game as LaneId, task.clock === undefined ? input.timing : undefined); const { start, days } = periodOf(task.period, today); const scope = task.period.unit; @@ -120,6 +123,25 @@ export function taskStatus(input: { }; } +/** + * A built-in that resets with its game's version (spec 2026-10-22): the number + * under the version's key, and whole days to a **recorded** end — none when + * only an estimate exists, since an estimate is never acted on. Amounts only + * (routines.test.ts holds every version routine to that); shown as a cycle. + */ +function versionStatus(game: string, measure: Measure, now: number, amounts: AmountLog | undefined): GoalStatus { + const w = versionWindow(game, now); + const key = w?.key ?? ""; + const count = amounts?.[key]?.value ?? 0; + const required = measure.kind === "amount" ? measure.target : measure.required; + const daysLeft = w?.end === undefined ? null : Math.ceil((w.end - now) / DAY); + return { + scope: "cycle", measure: "amount", periodStart: key, required, count, + met: count >= required, needed: Math.max(0, required - count), + open: daysLeft, shortfall: false, daysLeft, + }; +} + const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; function ordinal(n: number): string { @@ -137,9 +159,15 @@ export function asOfLabel(day: string): string { } /** One line saying when it rolls and what it wants. */ -export function taskCaption(task: Pick): string { +export function taskCaption( + task: Pick & { period: RoutinePeriod; game?: string }, + now: number = Date.now(), +): string { + const version = task.period.unit === "version" && task.game !== undefined ? versionWindow(task.game, now) : undefined; const when = - task.period.unit === "day" + task.period.unit === "version" + ? version === undefined ? "resets with the version" : versionCaption(version, now) + : task.period.unit === "day" ? "every day" : task.period.unit === "week" ? `every week from ${WEEKDAYS[task.period.startsOn - 1]}` diff --git a/test/routines.test.ts b/test/routines.test.ts index 15e4095..223c1d9 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -43,6 +43,13 @@ describe("ROUTINES", () => { } }); + test("a built-in may reset with the version, and then counts an amount", () => { + const { period: _p, ...rest } = ROUTINES[0]!; + expect(RoutineSchema.safeParse({ ...rest, period: { unit: "version" } }).success).toBe(true); + for (const r of ROUTINES) + if (r.period.unit === "version") expect(r.measure.kind).toBe("amount"); + }); + test("a routine without a source is refused", () => { const { source: _s, ...bare } = ROUTINES[0]!; expect(RoutineSchema.safeParse(bare).success).toBe(false); diff --git a/test/task.test.ts b/test/task.test.ts index e7aca8b..88e53fb 100644 --- a/test/task.test.ts +++ b/test/task.test.ts @@ -183,3 +183,53 @@ describe("a cycle chip's days left", () => { expect(at(["2026-10-29"], "2026-10-29")).toBe("1/1 · 1d"); }); }); + +// Spec 2026-10-22: a built-in can reset with its game's version, at the +// instant the table records — never at an estimate. +describe("a task that resets with the version", () => { + const shop = { + game: "wuwa", + period: { unit: "version" as const }, + measure: { kind: "amount" as const, target: 7, unit: "Radiant Tide" }, + }; + const amounts = { "2026-09-30": { value: 3, at: AT } }; + const status = (iso: string) => + taskStatus({ task: shop, region: "europe", now: Date.parse(iso), logged: [], amounts }); + + test("reads the number under the version's key, with whole days to its end", () => { + expect(status("2026-10-22T14:30:00Z")).toMatchObject({ + scope: "cycle", + measure: "amount", + periodStart: "2026-09-30", + count: 3, + required: 7, + met: false, + daysLeft: 20, + }); + }); + + test("a minute before the end it still holds; at the end it is a new version", () => { + expect(status("2026-11-11T10:29:00Z").count).toBe(3); + expect(status("2026-11-11T10:30:00Z")).toMatchObject({ + periodStart: "2026-11-11", + count: 0, + daysLeft: null, + open: null, + }); + }); + + test("the chip drops the days when no end is recorded", () => { + expect(goalCount(status("2026-10-22T14:30:00Z"))).toBe("3/7 · 20d"); + expect(goalCount(status("2026-11-12T00:00:00Z"))).toBe("0/7"); + }); + + test("its caption names the next version", () => { + expect(taskCaption(shop, Date.parse("2026-10-22T14:30:00Z"))).toBe( + "resets with 3.8 · 11 Nov · reach 7 Radiant Tide", + ); + }); + + test("a reader's task cannot hold it", () => { + expect(TaskSchema.safeParse(task({ period: { unit: "version" } as never })).success).toBe(false); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index 4a2a3fd..cdc5e72 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -16,6 +16,7 @@ import { dailyTally, longerHeading, tasksShown, + type StripTask, } from "../src/client/components/Dailies.tsx"; import type { GoalStatus } from "../src/shared/goal.ts"; import { Welcome } from "../src/client/components/Welcome.tsx"; @@ -1431,7 +1432,7 @@ describe("weekly and monthly tasks in the dailies strip", () => { scope: "cycle", measure: "days", periodStart: "2026-10-02", required: 1, count: 0, met: false, needed: 1, open: 1, shortfall: false, }; - const anyStatus = (t: Pick) => + const anyStatus = (t: StripTask) => t.period.unit === "cycle" ? cycleStatus : statusOf(t); test("the lower row names the periods it holds", () => {