diff --git a/src/client/App.tsx b/src/client/App.tsx index 03a4175..d5e1317 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -47,10 +47,10 @@ import { resolveDaily, } from "../shared/daily.ts"; import { isoWeekday } from "../shared/period.ts"; -import { tasksShown } from "./components/Dailies.tsx"; +import { tasksShown, type StripTask } from "./components/Dailies.tsx"; import type { StripEditor, StripEvent, StripStanding } from "./components/EditPanel.tsx"; import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; -import { taskStatus, type TaskShape } from "../shared/task.ts"; +import { taskStatus } from "../shared/task.ts"; import { activeRoutines, offered, @@ -253,7 +253,7 @@ export function App() { amounts.merge(copy.amounts); }; - const taskStatusOf = (task: TaskShape): GoalStatus => + const taskStatusOf = (task: StripTask): GoalStatus => taskStatus({ task, region: prefs.region, diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 43568a0..bd7d10a 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -72,13 +72,19 @@ export interface DailyItem { period?: Period | undefined; /** A task's unit, printed after its target. */ unit?: string | undefined; + /** + * The server whose reset this item follows, when not the reader's — a + * built-in on one clock for everyone. Its day, countdown and catch-up days + * are cut on it. + */ + clock?: Region | undefined; } /** * A task as the strip needs it: a reader's own, or a built-in routine, which * also says what date its source gives (`asOf`) so the tooltip can show it. */ -export type StripTask = TaskShape & { asOf?: string | undefined }; +export type StripTask = TaskShape & { asOf?: string | undefined; clock?: Region | undefined }; /** A game and everything of its that wants ticking today. */ export interface DailyGroup { @@ -183,6 +189,7 @@ export function dailyGroups( if (games.includes(lane)) { for (const task of tasks) { if (task.game !== lane) continue; + const clock = task.clock; items.push({ key: task.id, game: lane, @@ -194,8 +201,9 @@ export function dailyGroups( ? `${game.name} — ${task.title}` : `${game.name} — ${task.title} · built in, from a source as of ${asOfLabel(task.asOf)}`, ariaLabel: `${task.title} (${game.name})`, - today, - resetsIn, + today: clock === undefined ? today : dayKey(now, clock, lane), + resetsIn: clock === undefined ? resetsIn : msUntilReset(now, clock, lane), + ...(clock === undefined ? {} : { clock }), notBefore: null, goal: taskStatusFor(task), cadence: task.period.unit, @@ -718,7 +726,7 @@ export function CatchUpPanel({

{item.label}

- {catchUpDays(now, region, item.game, item.notBefore).map((day) => ( + {catchUpDays(now, item.clock ?? region, item.game, item.notBefore).map((day) => ( & TaskShape; diff --git a/src/shared/task.ts b/src/shared/task.ts index 9cd7102..aa183fb 100644 --- a/src/shared/task.ts +++ b/src/shared/task.ts @@ -68,14 +68,15 @@ export type TaskShape = Pick; + /** `clock`: a built-in's server, when it rolls on one server's reset for everyone. */ + task: Pick & { clock?: Region | undefined }; region: Region; now: number; logged: readonly string[]; amounts?: AmountLog | undefined; }): GoalStatus { const { task, region, now, logged, amounts } = input; - const today = dayKey(now, region, task.game as LaneId); + const today = dayKey(now, task.clock ?? region, task.game as LaneId); const { start, days } = periodOf(task.period, today); const scope = task.period.unit; const left = days.filter((k) => k >= today); diff --git a/test/routines.test.ts b/test/routines.test.ts index fb8d0b7..f08176c 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -304,6 +304,23 @@ describe("making a built-in your own", () => { measure: { kind: "amount", target: 2100, unit: "points" }, from: "routine:zzz:ridu-weekly", }); }); + + test("leaves a server clock behind: a reader's task has none", () => { + const ridu = routinesFor("zzz")[0]!; + expect("clock" in mineFrom({ ...ridu, clock: "asia" })).toBe(false); + }); +}); + +describe("a built-in on one server's clock", () => { + test("only a cycle has one", () => { + for (const r of ROUTINES) if (r.clock !== undefined) expect(r.period.unit).toBe("cycle"); + }); + + test("the schema reads it as a server", () => { + const ridu = routinesFor("zzz")[0]!; + expect(RoutineSchema.safeParse({ ...ridu, clock: "asia" }).success).toBe(true); + expect(RoutineSchema.safeParse({ ...ridu, clock: "mars" }).success).toBe(false); + }); }); describe("copying a built-in's record to the reader's copy", () => { diff --git a/test/task.test.ts b/test/task.test.ts index 1a87e8f..5c1f5a5 100644 --- a/test/task.test.ts +++ b/test/task.test.ts @@ -148,3 +148,26 @@ describe("a task on a cycle", () => { expect(taskStatus({ task: t, region: "europe", now: noon("2026-10-16"), logged: [] }).required).toBe(3); }); }); + +describe("a task on another server's clock", () => { + // NTE's Asia server rolls at 05:00 UTC+8, 21:00 UTC; its Europe server at 03:00 UTC. + const t = { + game: "nte", + period: { unit: "cycle" as const, days: 14, from: "2026-10-22" }, + measure: { kind: "days" as const, required: 1 }, + clock: "asia" as const, + }; + + test("its cycle rolls at that server's reset for a reader on another", () => { + const before = taskStatus({ task: t, region: "europe", now: Date.parse("2026-10-21T20:59:00Z"), logged: [] }); + const after = taskStatus({ task: t, region: "europe", now: Date.parse("2026-10-21T21:01:00Z"), logged: [] }); + expect(before.periodStart).toBe("2026-10-08"); + expect(after.periodStart).toBe("2026-10-22"); + }); + + test("without one, it rolls on the reader's own reset", () => { + const { clock: _, ...own } = t; + const after = taskStatus({ task: own, region: "europe", now: Date.parse("2026-10-21T21:01:00Z"), logged: [] }); + expect(after.periodStart).toBe("2026-10-08"); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index bcb6201..44955ee 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -1454,6 +1454,23 @@ describe("weekly and monthly tasks in the dailies strip", () => { .toEqual([shiyu]); }); + test("a clocked task's day and countdown are cut on that server's reset", () => { + const rails = { + id: "routine:nte:test-rails", game: "nte", title: "Beyond the Rails", + period: { unit: "cycle" as const, days: 14, from: "2026-10-22" }, + measure: { kind: "days" as const, required: 1 }, clock: "asia" as const, + }; + const at = Date.parse("2026-10-21T21:01:00Z"); + const [group] = dailyGroups(["nte"], [], at, "europe", meta, startOf, true, undefined, + [rails], () => undefined); + const [chore, item] = group!.items; + expect(chore!.today).toBe("2026-10-21"); + expect(item!.today).toBe("2026-10-22"); + expect(item!.clock).toBe("asia"); + // Asia's next reset is a day away; Europe's chore resets in about six hours. + expect(item!.resetsIn).toBeGreaterThan(chore!.resetsIn); + }); + test("a cycle chip shows its days left, under a heading that names it", () => { const html = render(