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(