feat(routines): a routine can reset with its game's version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34ebc401c7
commit
b3a84757a1
11 files changed
+135
-21
No files matched your search
@@ -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 extends Pick<StripTask, "period">>(
|
||||
): 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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)}`,
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-2
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export const RoutinePeriodSchema = z.union([
|
||||
]);
|
||||
export type RoutinePeriod = z.infer<typeof RoutinePeriodSchema>;
|
||||
|
||||
/** 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);
|
||||
|
||||
|
||||
@@ -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://"),
|
||||
|
||||
+33
-5
@@ -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<typeof TaskSchema>;
|
||||
* 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<Task, "id" | "game" | "title" | "period" | "measure">;
|
||||
export type TaskShape = Pick<Task, "id" | "game" | "title" | "measure"> & { 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<Task, "game" | "period" | "measure"> & { clock?: Region | undefined };
|
||||
task: Pick<Task, "game" | "measure"> & { 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<Task, "period" | "measure">): string {
|
||||
export function taskCaption(
|
||||
task: Pick<Task, "measure"> & { 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]}`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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<Task, "period" | "measure">) =>
|
||||
const anyStatus = (t: StripTask) =>
|
||||
t.period.unit === "cycle" ? cycleStatus : statusOf(t);
|
||||
|
||||
test("the lower row names the periods it holds", () => {
|
||||
|
||||
Reference in new issue
Block a user