feat(routines): a built-in can roll on one server's reset for everyone

Routine gains clock?: Region, code only. Its day key, its countdown and
its catch-up days are cut on that server's reset rather than the
reader's: for a cycle that ends at one instant on every server. Make it
mine leaves it behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 05:31:28 +02:00
1 parent be3c1408d2
commit 8eef068332
7 files changed
+83 -10

No files matched your search

+3 -3
View File
@@ -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,
+12 -4
View File
@@ -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({
<div key={item.key} className="mt-1.5">
<p className="truncate text-xs text-muted">{item.label}</p>
<div className="mt-1 flex flex-wrap gap-1">
{catchUpDays(now, region, item.game, item.notBefore).map((day) => (
{catchUpDays(now, item.clock ?? region, item.game, item.notBefore).map((day) => (
<DayPip
key={day}
day={day}
+8 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { PeriodSchema } from "./period.ts";
import { GameId } from "./schema.ts";
import { GameId, Region } from "./schema.ts";
import { shownFor, type PerGame } from "./perGame.ts";
import { MeasureSchema, type TaskShape } from "./task.ts";
@@ -47,6 +47,13 @@ export const RoutineSchema = z.object({
* (`prefs.standardPulls`, owner, 2026-10-16). Absent on everything else.
*/
pulls: z.enum(["premium", "standard"]).optional(),
/**
* The server whose reset this rolls on for every reader — for a cycle that
* ends at one instant everywhere, such as NTE's Beyond the Rails on Asia's
* (spec 2026-10-16, decision 3). Code only, never stored; *Make it mine*
* leaves it behind, since a reader's task has no such field.
*/
clock: Region.optional(),
});
export type Routine = z.infer<typeof RoutineSchema> & TaskShape;
+3 -2
View File
@@ -68,14 +68,15 @@ export type TaskShape = Pick<Task, "id" | "game" | "title" | "period" | "measure
/** Where the reader is with a task this period. Pure; `now` explicit. */
export function taskStatus(input: {
task: Pick<Task, "game" | "period" | "measure">;
/** `clock`: a built-in's server, when it rolls on one server's reset for everyone. */
task: Pick<Task, "game" | "period" | "measure"> & { 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);
+17
View File
@@ -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", () => {
+23
View File
@@ -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");
});
});
+17
View File
@@ -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(
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}