docs: design and plan exact end times for Genshin and Miliastra

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-11-07 01:57:54 +01:00
co-authored by Claude Opus 5.5
parent 52dbad820e
commit 74c91a7143
2 changed files with 355 additions and 0 deletions
@@ -0,0 +1,190 @@
# Exact End Times 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:** A live Genshin or Miliastra row shows the end its Fandom article states, to the minute, and otherwise behaves as today.
**Architecture:** The refresh stores the lead section of each listed event's article beside the other snapshots. The feed build parses those offline: `fandom.ts` uses them only to decide currency, and a pure step after `mergeEvents` writes the stated end onto the merged survivor.
**Tech Stack:** Bun 1.3, TypeScript strict, `bun test`. No new dependency.
**Spec:** `docs/superpowers/specs/2026-11-07-exact-end-times-design.md`
## Global Constraints
- `id`, `startsAt`, `sourceUrl`, `sourceId` and `classification` are never changed by this work.
- Only `genshin-fandom-events` rows are read. Other games are out of scope.
- The build and all tests never touch the network. Tests never need build output.
- No time is invented: only the `data-source="time_end"` infobox cell, in one of two printed shapes.
- Stored times are as printed (14:59, not 15:00).
- Article failures never affect a source's failure streak, health or broken-source count.
- Stage by path; a commit is its own command, never chained with an edit (AGENTS.md).
## Review Focus
- A percent-encoded title (`The_Forge_Realm%27s_Temper/2026-11-04`) must reach the API decoded and keep one stable file name. Test in Task 2.
- Two table rows linking the same article make one request per cycle. Test in Task 2.
- A row with no article link, whose `sourceUrl` is the API URL, makes no request and stays on fallback. Test in Task 2.
- An empty or unparsable source snapshot deletes no stored articles and throws nothing. Test in Task 2.
- A build with no `snapshots/articles` directory, as on a clean checkout, works and changes nothing. Test in Task 1.
---
### Task 1: Read and apply a stated end, and keep a live row live
**Files:**
- Create: `src/ingest/articleEnds.ts`
- Create: `fixtures/genshin/articles/hour-of-ruin-2026-11-07.html` (server time), `raiment-collection-2026-11-04-2026-11-07.html` (GMT+8): `section=0` API responses, captured once
- Modify: `src/ingest/adapters/types.ts` (`ParseContext`), `src/ingest/parsers/fandom.ts` (`parseGenshinEvents` currency check)
- Test: `test/article-ends.test.ts`
**Interfaces:**
- Produces, from `src/ingest/articleEnds.ts`:
- `interface StatedEnd { day: string /* YYYY-MM-DD as printed */; endsAt: string; regionEnds: Record<Region, string> | null }`
- `parseArticleEnd(body: string): StatedEnd | null`: `body` is the API's JSON; anything malformed is `null`
- `articleTitle(sourceUrl: string): string | null`: decoded page title for a `genshin-impact.fandom.com/wiki/…` URL, else `null`
- `latestStatedMs(end: StatedEnd): number`
- `applyArticleEnds(merged: GachaEvent[], listed: GachaEvent[], ends: ReadonlyMap<string, StatedEnd>, warn: (line: string) => void): GachaEvent[]`, where `listed` is the Fandom parse before merging and `ends` is keyed by `articleTitle`
- Produces on `ParseContext`: optional `articleEnds?: ReadonlyMap<string, StatedEnd>`
- [ ] **Step 1: Capture the two fixtures** with one request each, using the `section=0` URL from spec decision 5.
- [ ] **Step 2: Write the failing tests** in `test/article-ends.test.ts`
```ts
test("server time gives every region its own end", () => {
const end = parseArticleEnd(hourOfRuinFixture)!;
expect(end.day).toBe("2026-12-15");
expect(end.regionEnds).toEqual({
asia: "2026-12-15T06:59:00.000Z",
europe: "2026-12-15T13:59:00.000Z",
america: "2026-12-15T19:59:00.000Z",
});
expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z"); // earliest, as wikigg.ts does
});
test("GMT+8 is one instant", () => {
const end = parseArticleEnd(raimentFixture)!;
expect(end).toEqual({ day: "2026-12-16", endsAt: "2026-12-15T21:59:00.000Z", regionEnds: null });
});
test.each(["December 15, 2026", "TBA", "December 15, 2026 14:59 UTC", "December 15, 2026 14:59 GMT+9", ""])(
"%s is no usable time", /* fixture with the cell text replaced → null */);
test("malformed JSON and a page with no infobox are null");
test("the End header cell is not mistaken for the value"); // the <th> carries data-source too
test("a merged Game8 survivor takes the end through mergedIds");
// survivor id differs, mergedIds holds the Fandom id; regionScoped true, endPrecision "exact"
test("id, startsAt, sourceUrl, sourceId and classification never change");
test("a table date that disagrees keeps the row and warns");
test("a survivor date that disagrees with the table keeps the row and warns");
test("a survivor already at exact precision is left alone");
test("no articles means the same array contents as merged");
test("a live row outlives the table's day on its stated end", () => {
// parse the Fandom fixture row for an event at now = just past latestBoundaryMs of its date:
// with articleEnds holding a later stated end it is emitted; without, it is not.
});
```
Use `toEqual` against the input for the no-articles and unchanged-fields cases. The disagreeing-date cases use the real fixture with the End cell's date replaced.
- [ ] **Step 3: Run:** `bun test test/article-ends.test.ts`. Expected: FAIL, module not found.
- [ ] **Step 4: Implement `src/ingest/articleEnds.ts`.** Read `parse.text`, take `<td …data-source="time_end"…>…</td>`, flatten with `html.ts`'s `text`, then match only:
```ts
/^([A-Z][a-z]+ \d{1,2}, \d{4}) (\d{1,2}):(\d{2}) (\(Server Time\)|GMT\+8)$/
```
`(Server Time)` offsets come from `REGION_RESET_UTC_OFFSET`, not from `resetShiftMs`, whose game overrides concern resets. `applyArticleEnds` skips a survivor unless it has `endPrecision === "day"` (agent's call: an end another source stated exactly is not overridden) and both `listed.endsAt` and the survivor's `endsAt` start with `end.day`. It sets `endPrecision: "exact"`, `endsAt`, and `regionScoped`/`regionEnds` from the `StatedEnd`.
- [ ] **Step 5: Add `articleEnds?` to `ParseContext`, and in `parseGenshinEvents`** compute the row's retirement on `latestStatedMs` when `ctx.articleEnds?.get(articleTitle(href))` exists and its `day` equals the table's date, else `latestBoundaryMs` as now. Emit the same row either way; the parser never writes the stated end.
- [ ] **Step 6: Run:** `bun test test/article-ends.test.ts test/adapters/game8.test.ts`. Expected: PASS, including unchanged `.expected.json` rows.
- [ ] **Step 7: Commit** `src/ingest/articleEnds.ts`, `src/ingest/adapters/types.ts`, `src/ingest/parsers/fandom.ts`, `test/article-ends.test.ts`, `fixtures/genshin/articles/` as `feat(ingest): read a stated end from a Genshin article`.
### Task 2: Keep the articles fresh during a refresh
**Files:**
- Create: `src/ingest/articleStore.ts`, `scripts/refresh-articles.ts`
- Modify: `src/ingest/snapshots.ts` (export `writeAtomic`), `scripts/refresh-sources.ts` (`RefreshOptions`, `runRefresh`, `main`), `.gitignore`, `snapshots/README.md`
- Test: `test/article-refresh.test.ts`
**Interfaces:**
- Consumes: `articleTitle`, `parseArticleEnd` (Task 1).
- Produces, from `src/ingest/articleStore.ts`:
- `interface ArticleMeta { title: string; lastAttemptAt: string; lastReadAt: string | null; statesTime: boolean }`
- `class ArticleStore { constructor(root: string) /* <root>/articles/genshin-fandom */; readMeta(title); readBody(title); saveRead(title, body, at); recordAttempt(title, at); remove(title); listed(): Promise<string[]>; readEnds(): Promise<Map<string, StatedEnd>> }`
- `articleFileName(title: string): string`: unsafe characters as `_` plus the first 8 hex of the title's hash
- `articleDue(meta: ArticleMeta | null, nowMs: number): boolean`
- Produces, from `scripts/refresh-articles.ts`:
- `refreshArticles(input: { titles: string[]; store: ArticleStore; robots: RobotsGate; fetchImpl: FetchLike; userAgent: string; now: () => Date; sleep: (ms: number) => Promise<void>; dryRun: boolean; timeoutMs: number; log: (line: string) => void; requestedHosts: Set<string> }): Promise<{ changed: number; warnings: string[] }>`
- Produces on `RefreshOptions`: optional `articleSourceId?: string`; on `RefreshSummary`: `articlesChanged: number`
- [ ] **Step 1: Write the failing tests** in `test/article-refresh.test.ts`, driving `refreshArticles` with a stub fetch and a temp root:
```ts
test("due: never read"); // articleDue(null, now) === true
test("due: no time found, only after 6 hours");
test("due: time found, only 7 days after the last read that worked");
test("due: a failed attempt inside 6 hours is not due, even past 7 days");
test.each([500, 403])("HTTP %i keeps the body and the time, and warns");
test("a network error keeps the body and the time, and warns");
test("robots refusal makes no request, keeps the body, and warns");
test("404 removes the stored article and warns");
test("a 200 with a missingtitle API error removes the stored article and warns");
test("a 200 with no usable End overwrites the body, sets statesTime false, and logs without warning");
test("lastReadAt moves only on a successful read; lastAttemptAt on every attempt");
test("the requested URL carries the decoded title, section=0, and the runner User-Agent");
test("the same title twice is one request");
test("per-host spacing applies when the host was already asked this cycle");
test("a dry run requests and writes nothing");
test("a stored article no longer listed is removed");
```
And driving `runRefresh`:
```ts
test("article reads run only with articleSourceId set; existing callers are unaffected");
test("a row with no article link makes no article request");
test("an empty source snapshot removes nothing and does not throw");
test("article failures leave broken, hardFailure and the source's streak untouched");
test("a changed article triggers the rebuild even when no source page changed");
```
- [ ] **Step 2: Run:** `bun test test/article-refresh.test.ts`. Expected: FAIL, module not found.
- [ ] **Step 3: Implement `articleStore.ts`.** `articleDue` is true when `meta` is null, or when at least 6h have passed since `lastAttemptAt` and either `statesTime` is false or at least 7 days have passed since `lastReadAt`. Body before meta, each through `writeAtomic`. `readEnds` skips a file `parseArticleEnd` returns null for, and returns an empty map when the directory does not exist.
- [ ] **Step 4: Implement `refresh-articles.ts`** to spec decision 12's table. Sends no conditional headers: the API does not offer validators worth the state. Uses the `api.php` URL from spec decision 5 and the same 200-only, read-body-in-a-try discipline as `refreshOne`. Removes stored articles not in `titles` only when `titles` is non-empty.
- [ ] **Step 5: Wire `runRefresh`.** After the source loop and before the hard-failure returns, when `articleSourceId` is among `selected`, parse that source's stored snapshot with the adapter at `now`, map rows through `articleTitle`, drop nulls, and call `refreshArticles`, sharing `cycle.requestedHosts`. Wrap the whole step in `try`, turning a throw into one warning. Push its warnings to `summary.warnings`. Rebuild when `summary.changed > 0 || summary.articlesChanged > 0`. `main` passes `articleSourceId: "genshin-fandom-events"`. `--force` is not forwarded. A dry run lists what is due.
- [ ] **Step 6: Add `snapshots/articles/**/*.tmp-*` to `.gitignore`** and document the directory in `snapshots/README.md`, including why it holds no `.state.json`.
- [ ] **Step 7: Run:** `bun test test/article-refresh.test.ts test/refresh.test.ts test/snapshots.test.ts`. Expected: PASS.
- [ ] **Step 8: Commit** those paths as `feat(refresh): keep Genshin event articles beside the snapshots`.
### Task 3: Build it into the feed and record it
**Files:**
- Modify: `scripts/build-feed.ts`, `src/shared/changelog.ts`, `AGENTS.md`, `docs/INGESTION.md`, `docs/PRD.md`, `docs/2026-11-01-genshin-event-audit.md`, the spec's status line
- Test: `test/article-feed.test.ts`
**Interfaces:**
- Consumes: `ArticleStore.readEnds` (Task 2), `applyArticleEnds` and `ParseContext.articleEnds` (Task 1).
- [ ] **Step 1: Write the failing test** `test/article-feed.test.ts`: run `scripts/build-feed.ts` as a subprocess against a temp `SNAPSHOT_DIR` holding `genshin-fandom-events.html` and `.meta.json`, copied from a pinned `fixtures/genshin/articles/event-table-2026-11-06.html`, plus an `articles/genshin-fandom` copy of the Hour of Ruin fixture. Add two environment overrides to `build-feed.ts`, each defaulting to today's behaviour: `EVENT_FEED_OUT` for the output path, and `EVENT_FEED_NOW` pinned to `2026-11-07T00:00:00.000Z`, without which this test would fail once the fixture's events end. Assert Hour of Ruin's `regionEnds.europe` is `2026-12-15T13:59:00.000Z`, its `id` is `genshin:hour-of-ruin:2026-11-04`, and the feed still passes `EventFeed.parse`.
- [ ] **Step 2: Run:** `bun test test/article-feed.test.ts`. Expected: FAIL, row still at day precision.
- [ ] **Step 3: Wire `build-feed.ts`.** Read `ArticleStore.readEnds` once. Pass `articleEnds` in the context for `genshin-fandom-events` only. Keep that source's parsed rows. After `mergeEvents` for its game, apply `applyArticleEnds`, logging warnings as ` ! article: …`. Sort after applying.
- [ ] **Step 4: Run:** `bun test && bun run typecheck`. Expected: all pass.
- [ ] **Step 5: Check it for real, without touching the repo's data.** Copy `snapshots/` to a scratch directory, run `bun run refresh --snapshots <scratch> --only genshin-fandom-events`, then build from it with `SNAPSHOT_DIR=<scratch>` from an export. Expected: Test Run 17:59 server, Hour of Ruin 14:59 server, Raiment 05:59 GMT+8, passes 03:59 server. In a real browser on an HTTP test hostname: same countdowns on Europe, Raiment 38d 21h-style, and the detail sheet's "gave a date but no time" note gone from those rows but still shown for a Game8-only row.
- [ ] **Step 6: Update docs:** `AGENTS.md` § Domain rules (a Fandom article may state the end) and the Genshin § Fandom bullets; `docs/INGESTION.md` (article step, decision 12's table); PRD § Quality bar; the audit doc gets the Forge Realm reading and marks open item 1 done; the spec's status.
- [ ] **Step 7: Review:** run `schema-guardian` and fix what it finds.
- [ ] **Step 8: Commit** the feature paths as `feat(feed): show the end a Genshin article states`, then the changelog separately as `chore(changelog): note exact Genshin end times (<hash>)`, after `bun run scripts/check-changelog.ts`.
@@ -0,0 +1,165 @@
# Exact end times for Genshin and Miliastra — design
Date: 2026-11-07. Status: **approved** by the owner 2026-11-07; plan at `../plans/2026-11-07-exact-end-times.md`.
## Why
The owner trusts a countdown right up to the last hour before something closes.
Fandom's `Event` table prints dates only, so `clockFor` reads each end as that
game-day's reset. That is right for most events and hours wrong for the rest. It is
this product's named worst failure: a confidently wrong end date (PRD § Quality bar
for dates). The evidence is in `../../2026-11-01-genshin-event-audit.md` § Follow-up.
Every live row's article, read 2026-11-07, against what the app shows on Europe:
| Article's end | Rows | App today |
|---|---|---|
| 03:59 server time | 9: To Temper Cycle 6, Sacred Spirit Chronicle, Chronicle of Polyphony, Grand Defense, Stygian Onslaught, Stress Block, Frames of Fierce Splendor, Ley Line Overflow, Starlight Voyage | Correct |
| 05:59 GMT+8 | Raiment Collection, Forge Realm's Temper | 5h late on the article's reading |
| 14:59 server time | Encounter: Interlude, Hour of Ruin, Where Kristall Roots Run Deep | 11h early |
| 17:59 server time | Test Run | 14h early |
In-game readings by the owner agree with the articles on Sacred Spirit Chronicle,
Chronicle of Polyphony, Raiment Collection and Test Run. Encounter has indirect
support: `Wonderland Treasures - Polyphonic Harmony` read 38d 13h 47m, landing on
the same 14:59. **One disagrees:** the Cat's Tail timer for Forge Realm's Temper read
38d 22h at about 00:30 UTC on 2026-11-07, implying 15 Dec about 22:30–23:30 UTC. The
article implies 14 Dec 21:59 UTC. See § Accepted exception.
The miss is not a pattern a rule can fix: the same printed date can mean any of three
times, so each event's own stated time has to be read.
## Goals
- A live Genshin or Miliastra row shows the end its Fandom article states, to the minute.
- Reading the wiki stays polite: small requests, not repeated for what is already known.
- With no usable article time, a row behaves exactly as it does today.
## Non-goals
- Other games. Each has its own article layout; measure one when it is shown wrong.
- Start times. **`startsAt` must not change: it is half of the event ID.**
- The undated selector row, Wonderland Treasures, banners, and lane labels on Game8
rows (audit open items 2–4).
- New interface. Countdowns and printed end times become more accurate; nothing is added.
## Anti-goals: a failure even if it works
- An event ID changes, or a reader's marks are orphaned.
- A time is invented or inferred, including from an owner's rounded in-game reading.
- A row disappears from the feed while its stated end is still ahead.
- A refresh's failure on an article blocks or fails the page sources.
- The build touches the network.
## Constraints
No LLM, no new runtime dependency, no HTML parsing library. Deterministic parsing only,
per AGENTS.md § Three constraints and § Scraping conduct.
## Decisions
Approved with this document unless marked as the agent's call.
1. **Scope:** Genshin and Miliastra only (owner, 2026-11-07). Both come from
`genshin-fandom-events`, so this one source's rows are what gets read.
2. **Read an article when a row first appears, and keep what it said**, rather than
re-reading every article each refresh (owner, 2026-11-07, as a stated guess not
corrected).
3. **No usable time falls back to today's behaviour** (owner, same).
4. **Forge Realm's Temper is accepted as an exception,** not special-cased (owner,
2026-11-07, see below).
5. **Article reads:** `api.php?action=parse&page=<title>&prop=text&section=0&formatversion=2&format=json`.
Agent's call. `section=0` holds the infobox: 8 KB rather than about 18 KB, verified
on `Hour_of_Ruin_(Event)`. Same host, same `/api.php?action=` path the robots note
on `genshin-fandom-events` already clears, same runner User-Agent, robots gate and
per-host spacing.
6. **When to read.** Agent's call. Only rows the current table lists and that have not
ended. An article is due when it has never been read; when the last read found no
time, once the 6-hour floor has passed; or when the last read found a time at least
7 days ago, because wiki editors correct these and a kept wrong time would be
permanent. `--force` does not apply to articles.
7. **Storage: `snapshots/articles/genshin-fandom/<article-slug>.html` plus `.meta.json`,**
committed with the other snapshots. Agent's call. Raw bytes as served, written like
every other snapshot, so re-parsing never re-fetches and a changed time is a
reviewable diff. Rows no longer listed have their files deleted by the refresh.
8. **Read the End value of the infobox only:** the cell with `data-source="time_end"`.
Not the "Ends in" lines, the body prose, or the image names. The value has one of two
usable shapes:
| Printed | Stored | Precision |
|---|---|---|
| `December 15, 2026 14:59 (Server Time)` | `regionScoped: true`, `regionEnds` for each region at that server's clock: Asia UTC+8, Europe UTC+1, America UTC−5 | exact |
| `December 16, 2026 05:59 GMT+8` | one instant in `endsAt`, `regionScoped: false` | exact |
Anything else, including a date with no time or another zone, is no usable time.
Stored times are as printed, 14:59 and not 15:00.
9. **The article must agree on the date.** Its printed calendar date must equal the
date the table printed. If not, the time is ignored, the row keeps the table's date,
and the build logs a warning. A stale or wrongly linked article must not move an
event by days. This also keeps a wiki-side disagreement from silently shifting a row.
Agent's call: on a merged survivor the survivor's own end date must match as well. If
Game8 and Fandom disagree on the date, the row stays as merged, at day precision, with
a warning: `mergeEvents` flags such a disagreement for a person rather than resolving
it, and an article must not settle it unseen. No live row is in this state today.
10. **Apply after merge, in the feed build.** `mergeEvents` keeps the higher-confidence
copy, so Game8's day-precision row wins for Grand Defense, Stress Block, Frames and
Ley Line, and it would discard an end set on the Fandom copy. So the exact end is
applied to the merged survivor, matched by event ID or `mergedIds`. The merge's end
comparison keeps seeing table dates, so no new conflicts appear. Applying it changes
only `endsAt`, `endPrecision`, `regionScoped` and `regionEnds`; never `id`,
`startsAt`, `sourceUrl`, `sourceId` or `classification`. The application is a pure
function a test can reach, as `parseGame` keeps fetching out of parsing.
11. **Currency must follow the stated end.** `fandom.ts` retires a row on
`latestBoundaryMs` of the table's date. For Hour of Ruin that is 09:00Z while the
stated end is 19:59Z in the Americas, which would delete a live row for 11 hours on
its last day (an anti-goal). A row with a usable article time is retired on the
latest of those stated instants instead. How the parser receives this is left to the
builder; the parser stays offline and pure.
12. **Article failures are warnings, and what happens to a kept time depends on what the
wiki said.** Agent's call. None of these contributes to `genshin-fandom-events'`
failure streak, health or broken-source count.
| Result of a read | Stored article | Row shows |
|---|---|---|
| Network error, 5xx, refused, or robots-blocked: the wiki told us nothing about the article | Kept untouched | The last good time, if any. Due again after the 6-hour floor |
| 404 or a missing-page API error: the article is gone or renamed | Deleted | Fallback |
| `200` whose End value is absent or unusable: the article no longer states a time | Overwritten | Fallback. Due again under the no-time rule |
`.meta.json` records the last attempt separately from the last successful read, so the
7-day rule counts from a read that worked.
## Accepted exception
Forge Realm's Temper would go from about 20h early to about 25h early against the
Cat's Tail reading. It may be that the timer covers something beyond the event, or that
the article is wrong; neither is established. Both errors are on the safe side, so the
reader leaves the event done before it closes. It is one reading, so no special case.
Recheck when the next `The Forge Realm's Temper` run starts. Record the reading in the
audit doc in the same change.
Raiment Collection is the only confirmed-late row today, and it is corrected by this.
## Left to the builder
- Where the due-check and fetch sit in `scripts/refresh-sources.ts`, and the budget of
reads per run if one is needed. A new version adds about 15.
- Which form the region offsets take; reuse what `daily.ts`/`time.ts` already hold.
- What `endsAt` holds on a region-scoped row. Follow the existing wiki.gg precedent
and check `EventDetail.tsx`'s read of it.
- Whether a stale `firstSeenAt` or a bumped `version` applies; no change is the default.
## Verification
- Fixtures in `fixtures/genshin/` for one article of each shape, one stub with no time,
and one with a different date. Captured once, then tests stay offline.
- Tests: both shapes; rejected shapes; date disagreement, with the table and with a
merged survivor; each row of decision 12's table; application to a merged Game8
survivor via `mergedIds`; IDs and `startsAt` unchanged; a row still live on its last
day past the table's boundary; an article failure leaving source health untouched; the
due rule.
- `schema-guardian` reviews the change, since it writes `regionEnds` for this source.
- After a real refresh: on Europe, the table above reproduces at Test Run 17:59,
Hour of Ruin 14:59, Raiment 05:59 GMT+8 and passes at 03:59, in a real browser.
- `bun test`, `bun run typecheck`, and the docs changed with it: `AGENTS.md` § Domain
rules and § Fandom, `docs/INGESTION.md`, PRD § Quality bar, `snapshots/README.md`.
- A reader sees this, so it gets a changelog entry per AGENTS.md.