Files
gacha-event-tracker/docs/superpowers/plans/2026-10-16-cycle-routines.md
T
2026-10-16 05:22:09 +02:00

25 KiB
Raw Blame History

Built-ins on longer cycles — implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Tasks and built-ins can repeat every N days from a dated start, and the endgame cycles the owner approved ship as built-ins.

Architecture: A third PeriodSchema member, { unit: "cycle", days, from }, cut in day-key space by periodOf like weeks and months. GoalStatus.scope, the strip's cadence, its tally and its switches gain a cycle case. Built-ins may name a server clock whose reset they follow. Entries come last and wait on the owner.

Tech Stack: Bun, TypeScript, React, Zod, bun:test.

Spec: docs/superpowers/specs/2026-10-16-cycle-routines-design.md

Global Constraints

  • Every commit passes bun test and bun run typecheck on its own (AGENTS.md).
  • Commit messages end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • days: integer 2–366. from: YYYY-MM-DD. showCycleTasks defaults to true.
  • Copy: "this cycle", "done this cycle", short count n/m · Nd, "Every N days", "Last cycle:".
  • Never invent a figure: entries are exactly the spec's § Entries table.
  • Work happens on branch feat/cycle-routines in a worktree; the entries commits are last, and the branch is not merged until the owner confirms the spec's owner checks.
  • Push to origin only.

Review Focus

  1. A date before the anchor — periodOf must still give the cycle holding it (negative k), never the anchor's cycle. Pinned in Task 1.
  2. A cycle task whose length is under the days measure's required (a 3-day cycle asking 5 days) — taskStatus clamps to the cycle's length as it does for February. Pinned in Task 1.
  3. A clocked built-in for a Europe reader at Wednesday 20:59 vs 21:01 UTC — the day key and countdown must follow Asia, and the tick lands on Asia's key. Pinned in Task 3.
  4. Editing a reader's cycle task in the form must keep days and from, never silently turn it into a week. Pinned in Task 2.
  5. An older stored prefs blob without showCycleTasks reads as on; junk reads as on. Pinned in Task 1.

Task 1: The cycle period, read everywhere

One commit: feat(tasks): a task can repeat every N days from a dated start.

Files:

  • Modify: src/shared/period.ts, src/shared/goal.ts, src/shared/task.ts
  • Modify: src/client/components/Dailies.tsx, EditPanel.tsx, Controls.tsx, TaskForm.tsx (guard only), src/client/state/usePrefs.ts, src/client/App.tsx
  • Test: test/period.test.ts, test/goal.test.ts, test/task.test.ts, test/prefs.test.ts, test/views.test.tsx, test/edit-dailies.test.tsx, test/controls.test.tsx

Interfaces — produces:

  • PeriodSchema member { unit: "cycle"; days: number; from: string }.

  • GoalStatus.scope: "week" | "month" | "cycle" | "event".

  • DailyItem.cadence: "day" | "week" | "month" | "cycle" | "event".

  • tasksShown(tasks, { weekly, monthly, cycle }); dailyTally(...) returns { …, week, month, cycle }.

  • Prefs.showCycleTasks: boolean; EditPanel shown.cycle, hasTasks.cycle; onShown({ cycle }).

  • longerHeading(cadences: ReadonlySet<"week" | "month" | "cycle">): string exported from Dailies.tsx.

  • Step 1: Failing tests for periodOf — in test/period.test.ts:

describe("periodOf: cycles", () => {
  const cycle = (days: number, from: string) => ({ unit: "cycle" as const, days, from });

  test("the anchor starts its own cycle, which holds `days` keys", () => {
    const p = periodOf(cycle(14, "2026-10-16"), "2026-10-16");
    expect(p.start).toBe("2026-10-16");
    expect(p.days.length).toBe(14);
    expect(p.days.at(-1)).toBe("2026-10-29");
  });

  test("the last day is in it; the day after starts the next", () => {
    expect(periodOf(cycle(14, "2026-10-16"), "2026-10-29").start).toBe("2026-10-16");
    expect(periodOf(cycle(14, "2026-10-16"), "2026-10-30").start).toBe("2026-10-30");
  });

  test("a day before the anchor is in the cycle before it, not the anchor's", () => {
    expect(periodOf(cycle(42, "2026-11-16"), "2026-10-16").start).toBe("2026-10-05");
    expect(periodOf(cycle(14, "2026-10-16"), "2026-10-15").start).toBe("2026-10-02");
  });

  test("lengths 2 and 366", () => {
    expect(periodOf(cycle(2, "2026-01-01"), "2026-01-04").days).toEqual(["2026-01-03", "2026-01-04"]);
    expect(periodOf(cycle(366, "2026-01-01"), "2027-01-01").days.length).toBe(366);
  });

  test("the schema bounds", () => {
    expect(PeriodSchema.safeParse(cycle(1, "2026-01-01")).success).toBe(false);
    expect(PeriodSchema.safeParse(cycle(367, "2026-01-01")).success).toBe(false);
    expect(PeriodSchema.safeParse(cycle(14, "16 Oct")).success).toBe(false);
  });
});

(import PeriodSchema beside periodOf.)

  • Step 2: Run bun test test/period.test.ts — expect FAIL (schema refuses / periodOf falls into the month branch).

  • Step 3: Implement in src/shared/period.ts:

const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);

export const PeriodSchema = z.union([
  z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }),
  /** Day of month; 31 means "the last day" in a shorter month. */
  z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }),
  /**
   * Every `days` game-days, counted from `from`, a game-day on which a cycle
   * began — any one, not necessarily the first: cycles run both ways from it.
   */
  z.object({ unit: z.literal("cycle"), days: z.number().int().min(2).max(366), from: DayKey }),
]);

and in periodOf, before the month branch:

  } else if (period.unit === "cycle") {
    const since = Math.round((msOf(today) - msOf(period.from)) / DAY);
    start = addDays(period.from, Math.floor(since / period.days) * period.days);
    next = addDays(start, period.days);
  } else {

Update the file's header comment: "Periods longer than a day — a week, a month, a cycle of N days".

  • Step 4: Run bun test test/period.test.ts — PASS. Then bun run typecheck and note each error; Steps 5–10 fix them.

  • Step 5: goal.ts — scope: "week" | "month" | "cycle" | "event" (update its comment: month and cycle are a task's period). goalCount:

export function goalCount(s: GoalStatus): string {
  // "cy" means nothing; a cycle's days left say where it is.
  if (s.scope === "cycle") return `${num(s.count)}/${num(s.required)} · ${s.open ?? 0}d`;
  const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : "";
  return `${num(s.count)}/${num(s.required)}${unit}`;
}

within and doneLabel already read this ${s.scope}, which gives "this cycle". Test in test/goal.test.ts:

test("a cycle reads 'this cycle', and its short count is the days left", () => {
  const s: GoalStatus = { scope: "cycle", measure: "days", periodStart: "2026-10-16", required: 1, count: 0, met: false, needed: 1, open: 9, shortfall: false };
  expect(goalCount(s)).toBe("0/1 · 9d");
  expect(goalLabel(s)).toBe("0 of 1 this cycle");
  expect(goalLabel({ ...s, count: 1, met: true, needed: 0 })).toBe("done this cycle");
});
  • Step 6: task.ts — taskStatus's scope = task.period.unit already types as the new union. taskCaption:
  const when =
    task.period.unit === "week"
      ? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
      : task.period.unit === "month"
        ? `every month from the ${ordinal(task.period.startsOn)}`
        : `every ${task.period.days} days from ${asOfLabel(task.period.from)}`;

Move asOfLabel and MONTHS from routines.ts to task.ts and re-export from routines.ts (export { asOfLabel } from "./task.ts";) — routines.ts imports task.ts already, the reverse would cycle. Tests in test/task.test.ts:

test("a cycle task: status by its cycle, caption with its start", () => {
  const t = task({ period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 5 } });
  expect(TaskSchema.safeParse(t).success).toBe(true);
  expect(taskCaption(t)).toBe("every 14 days from 16 Oct 2026 · 5 days");
  const s = taskStatus({ task: t, region: "europe", now: noon("2026-10-20"), logged: ["2026-10-15", "2026-10-16", "2026-10-18"] });
  expect(s).toMatchObject({ scope: "cycle", periodStart: "2026-10-16", count: 2, required: 5, open: 10 });
});

test("a short cycle never asks for more days than it holds", () => {
  const t = task({ period: { unit: "cycle", days: 3, from: "2026-10-16" }, measure: { kind: "days", required: 5 } });
  expect(taskStatus({ task: t, region: "europe", now: noon("2026-10-16"), logged: [] }).required).toBe(3);
});
  • Step 7: usePrefs.ts — add after showMonthlyTasks:
  /** The same for tasks that repeat every N days (period.ts). */
  showCycleTasks: boolean;

default showCycleTasks: true, and in the restore normaliser:

  result.showCycleTasks = typeof result.showCycleTasks === "boolean" ? result.showCycleTasks : base.showCycleTasks;

Test in test/prefs.test.ts beside the existing weekly/monthly one:

test("showCycleTasks is on when absent or unreadable", () => {
  expect(defaults().showCycleTasks).toBe(true);
  expect(restorePrefsValue({}).showCycleTasks).toBe(true);
  expect(restorePrefsValue({ showCycleTasks: "no" }).showCycleTasks).toBe(true);
  expect(restorePrefsValue({ showCycleTasks: false }).showCycleTasks).toBe(false);
});
  • Step 8: Dailies.tsx

    • cadence: "day" | "week" | "month" | "cycle" | "event"; doc comment says weekly, monthly and cycle tasks.
    • tasksShown(tasks, shown: { weekly: boolean; monthly: boolean; cycle: boolean }):
      const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const;
      return tasks.filter((t) => on[t.period.unit]);
      
    • splitByCadence: longer = (i) => i.cadence === "week" || i.cadence === "month" || i.cadence === "cycle".
    • dailyTally gains cycle: PeriodTally; the branch picks i.cadence === "week" ? week : i.cadence === "month" ? month : cycle.
    • New, exported:
      /** The lower row's heading: the periods it holds, in order. */
      export function longerHeading(cadences: ReadonlySet<"week" | "month" | "cycle">): string {
        const names = (["week", "month", "cycle"] as const).filter((c) => cadences.has(c));
        if (names.length === 0) return "";
        const last = names.at(-1)!;
        const head = names.slice(0, -1).join(", ");
        return `This ${head === "" ? last : `${head} & ${last}`}`;
      }
      
      Render {longerHeading(new Set(longer.map((i) => i.cadence as "week" | "month" | "cycle")))} in place of This week &amp; month.
    • Header tally: add {cycle.total > 0 && <>Cycles {cycle.complete}/{cycle.total}</>} with " · " separators between any two present (build the parts as an array and join).
    • Footer: replace the two week/month sentences with ones over const longerOpen = week.total + month.total + cycle.total - week.complete - month.complete - cycle.complete; and a period phrase periodsPhrase(week, month, cycle) = the present ones joined with ", " and " or " ("this week or month", "this week, month or cycle"); "All done for this week and month." becomes All done for ${…joined with " and "}. Keep the existing strings exactly when only week and month are present (existing tests pin them).
    • Amount editor label: this ${scope} already yields "this cycle".
    • Catch up label: Last ${item.period.unit}: already yields "Last cycle:". Tests in test/views.test.tsx (next to the existing dailyTally tests; update the toEqual at line ~1387 to include cycle: { total: 0, complete: 0 }):
    test("the lower row names the periods it holds", () => {
      expect(longerHeading(new Set(["week"]))).toBe("This week");
      expect(longerHeading(new Set(["week", "month"]))).toBe("This week & month");
      expect(longerHeading(new Set(["week", "month", "cycle"]))).toBe("This week, month & cycle");
      expect(longerHeading(new Set(["cycle"]))).toBe("This cycle");
    });
    test("cycle tasks are tallied apart and switched apart", () => {
      const t = (unit: "week" | "cycle") => ({ period: unit === "week" ? { unit, startsOn: 1 } : { unit, days: 14, from: "2026-10-16" } });
      expect(tasksShown([t("week"), t("cycle")], { weekly: true, monthly: true, cycle: false })).toHaveLength(1);
    });
    

    plus a render test that a cycle task in the strip shows 0/1 · …d and the heading "This week & cycle" (use the file's existing dailyGroups/render helpers with a cycle StripTask).

  • Step 9: Switches — EditPanel.tsx: shown and hasTasks gain cycle; a third Switch label="Cycle tasks" under hasTasks.cycle. Controls.tsx: taskPeriods.cycle = periods.includes("cycle"); a third Check under it:

    <Check
      checked={prefs.showCycleTasks}
      onChange={(showCycleTasks) => onUpdate({ showCycleTasks })}
      label="Show cycle tasks under the dailies"
      hint="Tasks that repeat every so many days, like a two-week endgame mode. Off hides them from the strip and its counts; your ticks and numbers are kept."
    />
    

    App.tsx: pass cycle: prefs.showCycleTasks in both shown and tasksShown, hasTasks.cycle: stripTasks.some((t) => t.period.unit === "cycle"), and ...(patch.cycle !== undefined ? { showCycleTasks: patch.cycle } : {}). Update fixtures in test/edit-dailies.test.tsx (hasTasks, shown) and test/controls.test.tsx (showCycleTasks: true); add one test per file that the cycle switch appears only with a cycle task and writes showCycleTasks.

  • Step 10: TaskForm.tsx guard (replaced in Task 2): the initial unit reads initial?.period.unit === "month" ? "month" : "week". No reader can hold a cycle task yet in this commit.

  • Step 11: Run bun test and bun run typecheck — all pass (print the summary lines, not a tail that can hide a fail count).

  • Step 12: Commit feat(tasks): a task can repeat every N days from a dated start.

Task 2: Add a task offers "Every N days"

Commit: feat(tasks): set up a task that repeats every N days.

Files: Modify src/client/components/TaskForm.tsx; Test test/form-examples.test.tsx (or the file holding taskDraftFrom tests — grep -ln taskDraftFrom test/).

Interfaces — consumes Task 1's PeriodSchema cycle member. Produces TaskFields.unit: "week" | "month" | "cycle", TaskFields.cycleDays: string, TaskFields.cycleFrom: string.

  • Step 1: Failing tests
const base: TaskFields = { game: "zzz", title: "Shiyu Defense", unit: "cycle", weekday: 1, monthDay: "1", cycleDays: "14", cycleFrom: "2026-10-16", kind: "days", required: "1", target: "", amountUnit: "" };

test("every N days becomes a cycle period", () => {
  expect(taskDraftFrom(base)?.period).toEqual({ unit: "cycle", days: 14, from: "2026-10-16" });
});
test("a cycle needs a length of 2–366 and a start date", () => {
  expect(taskStillNeeded({ ...base, cycleDays: "1" })).toContain("a length of 2 to 366 days");
  expect(taskStillNeeded({ ...base, cycleDays: "" })).toContain("a length of 2 to 366 days");
  expect(taskStillNeeded({ ...base, cycleFrom: "" })).toContain("a start date");
});
test("days needed is bounded by the cycle and by 31", () => {
  expect(taskStillNeeded({ ...base, cycleDays: "3", required: "4" })).toContain("how many days");
  expect(taskStillNeeded({ ...base, cycleDays: "42", required: "32" })).toContain("how many days");
});
test("editing a cycle task keeps its length and start", () => {
  // render <TaskForm initial={{ game: "zzz", title: "x", period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 } }} …/>,
  // press Save, and expect onSave's draft.period to equal the initial period.
});
  • Step 2: Run — FAIL.

  • Step 3: Implement

    • TaskFields: unit: "week" | "month" | "cycle", cycleDays: string, cycleFrom: string.
    • Initial state: unit: initial?.period.unit ?? "week", cycleDays: initial?.period.unit === "cycle" ? String(initial.period.days) : "14", cycleFrom: initial?.period.unit === "cycle" ? initial.period.from : "".
    • parseCycleDays(raw): number | null — integer 2–366 or null.
    • taskStillNeeded: for cycle, push "a length of 2 to 366 days" when parseCycleDays is null and "a start date" when cycleFrom is not YYYY-MM-DD; days bound most = unit === "week" ? 7 : unit === "month" ? 31 : Math.min(31, parseCycleDays(cycleDays) ?? 31).
    • taskDraftFrom: period for cycle { unit: "cycle", days: parseCycleDays(f.cycleDays)!, from: f.cycleFrom } (still-needed already guarantees both).
    • UI: a third pill Every N days after Every month; when chosen, every [number 2–366, aria-label "Cycle length in days"] days, from [<input type="date" aria-label="Cycle starts on">]; the hint under it for cycle: "Pick a day a cycle began — the day after one ended. It rolls at the game's daily reset, on your server." The days-needed caption: this ${f.unit} reads "this cycle".
    • withTaskTemplate unchanged (templates set week or month).
  • Step 4: Run the form tests, then bun test and bun run typecheck — PASS.

  • Step 5: Browser check — build (bun run build:css && bun run build:js && bun run build:static), serve on a fresh port (PORT=<free> bun run serve.ts, backgrounded, PID noted), drive with puppeteer-core (/usr/bin/google-chrome-stable) at 390×844: open Your own → Add a task, choose Every N days, fill 14 / a date, save; the strip shows the chip with · Nd under "This … cycle"; open it again in the form and see 14 and the date. Screenshot to the scratchpad. Stop only that PID.

  • Step 6: Commit feat(tasks): set up a task that repeats every N days.

Task 3: A built-in can follow another server's clock

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

Files: Modify src/shared/routines.ts, src/shared/task.ts, src/client/components/Dailies.tsx, src/client/App.tsx (if taskStatusOf drops fields); Test test/routines.test.ts, test/task.test.ts, test/views.test.tsx.

Interfaces — produces: RoutineSchema.clock?: Region; taskStatus({ task: Pick<Task,"game"|"period"|"measure"> & { clock?: Region } , … }); StripTask = TaskShape & { asOf?: string; clock?: Region }; DailyItem.clock?: Region.

  • Step 1: Failing tests

    • task.test.ts:
      test("a clocked task's day is cut on that server's reset", () => {
        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 };
        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");
      });
      
      (Asia NTE resets 05:00 UTC+8 = 21:00 UTC — check games.ts for NTE's Asia offset before relying on it; if it is not 21:00 UTC, stop and report.)
    • views.test.tsx: dailyGroups with a clocked StripTask at 2026-10-21T21:01Z for a Europe reader gives that item today: "2026-10-22" and clock: "asia", while the game's chore keeps today: "2026-10-21".
    • routines.test.ts: test("a clock is only on cycles", () => { for (const r of ROUTINES) if (r.clock !== undefined) expect(r.period.unit).toBe("cycle"); }).
  • Step 2: Run — FAIL.

  • Step 3: Implement

    • RoutineSchema: clock: Region.optional() with doc: "The server whose reset this rolls on for every reader — a cycle that ends at one instant everywhere (spec 2026-10-16, decision 3). Code only, never stored; Make it mine drops it." Import Region from schema.ts.
    • taskStatus: const today = dayKey(now, task.clock ?? region, task.game as LaneId);.
    • Dailies.tsx task loop: const clock = task.clock; const itemToday = clock === undefined ? today : dayKey(now, clock, lane); const itemResets = clock === undefined ? resetsIn : msUntilReset(now, clock, lane); and set today: itemToday, resetsIn: itemResets, ...(clock === undefined ? {} : { clock }).
    • DailyItem.clock?: Region | undefined — "the server whose reset this item follows, when not the reader's".
    • CatchUpPanel: catchUpDays(now, item.clock ?? region, item.game, item.notBefore).
    • mineFrom already lists fields explicitly, so clock is not copied; add a test in routines.test.ts § making a built-in your own that "clock" in mineFrom({...r, clock: "asia"}) is false.
  • Step 4: Run bun test, bun run typecheck — PASS.

  • Step 5: Commit.

Task 4: Docs

Commit: docs: record every-N-days periods, their switch and built-in clocks.

  • docs/DATA-MODEL.md: the tasks storage line (period: … | { unit: "cycle", days: 2–366, from: "YYYY-MM-DD" }), the prefs list (showCycleTasks), the § on how periods are cut (cycles: from + k·days, both ways), and the stored-shape note from the spec (an older bundle skips a cycle task on display and deletes nothing).
  • docs/PRD.md tasks section (~line 578): "every week, every month, or every N days from a start date"; the switches sentence names showCycleTasks; F17 mentions longer cycles and clock.
  • Run bun test (docs tests, if any, e.g. changelog/doc link checks). Commit.

Task 5: Schema review

  • Run the schema-guardian agent on the branch diff (git diff main...HEAD) — stored keys gacha-tracker:v1:tasks, gacha-tracker:v1:prefs, amounts keyed by cycle start. Fix anything it finds in its own commit before the entries.

Task 6: The entries (one commit per game; held for the owner)

Each entry exactly as the spec's § Entries; checked: "2026-10-16"; ids as listed. Add each to ROUTINES after that game's existing items, and extend that game's line in the "each game's, as sourced" test with ["<title>", { unit: "cycle", days: N, from: "<key>" }, <measure>]. Commits, in order:

  • feat(routines): Star Rail's endgame modes and weekly points — Pure Fiction / Memory of Chaos / Apocalyptic Shadow (cycle 42; from 2026-10-19 / 2026-11-02 / 2026-11-16; { kind: "days", required: 1 }; source https://honkai-star-rail.fandom.com/wiki/Interastral_Peace_Guide/Treasures_Lightward, asOf: "2026-08-15"; note on each "Six-week rotation, one mode every two weeks.") and Divergent Universe & Currency Wars points (week Monday; { kind: "amount", target: 18000, unit: "points" }; source https://honkai-star-rail.fandom.com/wiki/Version/4.2, asOf: "2026-09-07"; note "One cap shared by both modes; weekly since 4.2. Confirmed in game, 2026-10-14.").
  • feat(routines): ZZZ's Shiyu Defense and Deadly Assault — cycle 14 from 2026-10-16 (https://zenless-zone-zero.fandom.com/wiki/Shiyu_Defense/Critical_Node, asOf: "2026-01-02", note "Fridays, alternating weeks with Deadly Assault.") and from 2026-10-23 (https://zenless-zone-zero.fandom.com/wiki/Deadly_Assault, asOf: "2026-07-31", note "Fridays, alternating weeks with Shiyu Defense.").
  • feat(routines): Wuthering Waves' Tower of Adversity — cycle 28 from 2026-11-09, https://gamemarket.gg/news/wuthering-waves/wuthering-waves-tower-of-adversity-guide-zones-vigor-rewards, asOf: "2026-08-25", note "The Hazard Zone; the source says roughly 28 days."
  • feat(routines): NTE's Beyond the Rails and Tycoon Incentive Fund — Beyond the Rails: cycle 14 from 2026-10-22, clock: "asia", https://gamewith.net/nte/74216, asOf: "2026-08-13", note "Ends at the Asia server's reset on every server, as read on Europe 2026-10-16."; Tycoon Incentive Fund: cycle 14 from 2026-10-26, { kind: "amount", target: 1000000, unit: "fons" }, https://allthings.how/neverness-to-everness-tycoon-encouragement-fund-what-changed-for-pink-paws-heist/, asOf: "2026-08-15", note "The base cap; the Puka Membership Program raises yours. Make it mine to set it."
  • Every note ≤ 140 chars (the schema enforces it; the test suite parses ROUTINES). bun test + typecheck after each.
  • Spec § Entries / built-in routines spec § Not shipped rows: move the shipped items out of the older spec's Not shipped table into a line pointing at the new spec, in the HSR commit (DU/CW, the three modes), ZZZ commit, Wuwa commit (Tower only — Whimpering Wastes stays), NTE commit.

Task 7: Browser check of the entries, TODO, changelog

  • Build and serve on a fresh port; with built-ins on for Star Rail, ZZZ, Wuwa and NTE at phone width, see the cycle chips and their day counts, the heading, the header tally; screenshot. Stop only that PID.
  • TODO.md: sub-project 2 marked done with a pointer to the spec; new open items: Whimpering Wastes (needs a "last Monday of the month" period, or a source for a fixed length) and Nikki's Pinnacle Contest (the 1st and 16th — a half-month period). Commit docs(todo): ….
  • src/shared/changelog.ts: entries for the period, the form, and the new built-ins, in a chore(changelog) commit naming the feature commits; run bun run scripts/check-changelog.ts.
  • Ask the owner for the spec's four in-game checks; merge (fast-forward) and push to origin only once they are confirmed.