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:
1 parent
be3c1408d2
commit
8eef068332
7 files changed
+83
-10
No files matched your search
+3
-3
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user