feat(endfield): track AIC Quota Exchange permits by banner rotation

The shop's five permits restock when the character and weapon banners change together, which no week, month or fixed cycle can express: rotations ran 21 days and then 19. A rotation is not a game version, so it gets its own table and caption, and follows the version rule that only a recorded end resets the count. The dates and reset rule are the owner's in-game reading, as no page states them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-06 09:14:38 +01:00
1 parent be80e785ca
commit 327f6389cc
16 files changed
+242 -46

No files matched your search

+3 -3
View File
@@ -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, cadenceOf, periodOf, type Period } from "../../shared/period.ts";
import { addDays, cadenceOf, isTimerPeriod, 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";
@@ -207,8 +207,8 @@ export function dailyGroups(
notBefore: null,
goal: taskStatusFor(task),
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,
// A timer has no period before it to catch up on (spec 2026-10-22).
period: isTimerPeriod(task.period) ? undefined : task.period,
unit: task.measure.kind === "amount" ? task.measure.unit : undefined,
});
}
@@ -22,8 +22,8 @@ import {
type Routine,
} 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";
import { isTimerPeriod, periodOf, type Period } from "../../../shared/period.ts";
import { timerWindow, versionCaption } from "../../../shared/versions.ts";
const capitalise = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
import { AmountTarget } from "../../../shared/goal.ts";
@@ -379,16 +379,16 @@ 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)
const timer =
def && isTimerPeriod(def.period)
? timerWindow(def.period, def.game, now)
: undefined;
const line = first.event ? ["Battle pass", "Uses its sourced window", "Mark complete when finished"] : def
? [
def.period.unit === "version"
? version
? capitalise(versionCaption(version, now))
: "Resets with the version"
isTimerPeriod(def.period)
? timer
? capitalise(versionCaption(timer, now))
: `Resets with the ${def.period.unit}`
: scheduleWords(
def.period,
first.clock.resetHour,
@@ -21,8 +21,8 @@ import { routineRows, type RoutineRow } from "../../state/reader/routines.ts";
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 { versionCaption, versionWindow } from "../../../shared/versions.ts";
import { isTimerPeriod, periodOf } from "../../../shared/period.ts";
import { timerWindow, versionCaption } from "../../../shared/versions.ts";
import { routinesFor } from "../../../shared/routines.ts";
import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts";
import { orderGames } from "../../state/gameOrder.ts";
@@ -57,12 +57,12 @@ export function routinePeriod(
if (row.window) return { ...row.window, estimated: false };
const key = readerDayKey(now, row.clock),
task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
if (task?.period.unit === "version") {
const w = versionWindow(row.game, now);
if (task && isTimerPeriod(task.period)) {
const w = timerWindow(task.period, row.game, now);
if (w?.end !== undefined) return { start: w.start, end: w.end, estimated: false };
if (w) return { start: w.start, end: Math.max(w.estimate ?? 0, now + DAY), estimated: true };
}
const period = task && task.period.unit !== "version"
const period = task && !isTimerPeriod(task.period)
? periodOf(task.period, key)
: { start: key, days: [key] };
const shift = resetShiftMs(row.clock.region, row.game, row.clock);
@@ -72,8 +72,10 @@ export function routinePeriod(
/** What to print instead of a countdown when `routinePeriod` is estimated. */
export const estimatedReset = (row: RoutineRow, now: number): string => {
const w = versionWindow(row.game, now);
return w ? versionCaption(w, now) : "resets with next version";
const task = routinesFor(row.game).find((r) => r.id === row.id),
period = task && isTimerPeriod(task.period) ? task.period : ({ unit: "version" } as const),
w = timerWindow(period, row.game, now);
return w ? versionCaption(w, now) : `resets with next ${period.unit}`;
};
export function TimelinePage({
state,