14 KiB
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,sourceIdandclassificationare never changed by this work.- Only
genshin-fandom-eventsrows 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
sourceUrlis 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/articlesdirectory, 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=0API responses, captured once - Modify:
src/ingest/adapters/types.ts(ParseContext),src/ingest/parsers/fandom.ts(parseGenshinEventscurrency 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:bodyis the API's JSON; anything malformed isnullarticleTitle(sourceUrl: string): string | null: decoded page title for agenshin-impact.fandom.com/wiki/…URL, elsenulllatestStatedMs(end: StatedEnd): numberapplyArticleEnds(merged: GachaEvent[], listed: GachaEvent[], ends: ReadonlyMap<string, StatedEnd>, warn: (line: string) => void): GachaEvent[], wherelistedis the Fandom parse before merging andendsis keyed byarticleTitle
-
Produces on
ParseContext: optionalarticleEnds?: ReadonlyMap<string, StatedEnd> -
Step 1: Capture the two fixtures with one request each, using the
section=0URL from spec decision 5. -
Step 2: Write the failing tests in
test/article-ends.test.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. Readparse.text, take<td …data-source="time_end"…>…</td>, flatten withhtml.ts'stext, then match only:
/^([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?toParseContext, and inparseGenshinEventscompute the row's retirement onlatestStatedMswhenctx.articleEnds?.get(articleTitle(href))exists and itsdayequals the table's date, elselatestBoundaryMsas 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.jsonrows. -
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/asfeat(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(exportwriteAtomic),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 hasharticleDue(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: optionalarticleSourceId?: string; onRefreshSummary:articlesChanged: number -
Step 1: Write the failing tests in
test/article-refresh.test.ts, drivingrefreshArticleswith a stub fetch and a temp root:
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:
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.articleDueis true whenmetais null, or when at least 6h have passed sincelastAttemptAtand eitherstatesTimeis false or at least 7 days have passed sincelastReadAt. Body before meta, each throughwriteAtomic.readEndsskips a fileparseArticleEndreturns null for, and returns an empty map when the directory does not exist. -
Step 4: Implement
refresh-articles.tsto spec decision 12's table. Sends no conditional headers: the API does not offer validators worth the state. Uses theapi.phpURL from spec decision 5 and the same 200-only, read-body-in-a-try discipline asrefreshOne. Removes stored articles not intitlesonly whentitlesis non-empty. -
Step 5: Wire
runRefresh. After the source loop and before the hard-failure returns, whenarticleSourceIdis amongselected, parse that source's stored snapshot with the adapter atnow, map rows througharticleTitle, drop nulls, and callrefreshArticles, sharingcycle.requestedHosts. Wrap the whole step intry, turning a throw into one warning. Push its warnings tosummary.warnings. Rebuild whensummary.changed > 0 || summary.articlesChanged > 0.mainpassesarticleSourceId: "genshin-fandom-events".--forceis not forwarded. A dry run lists what is due. -
Step 6: Add
snapshots/articles/**/*.tmp-*to.gitignoreand document the directory insnapshots/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),applyArticleEndsandParseContext.articleEnds(Task 1). -
Step 1: Write the failing test
test/article-feed.test.ts: runscripts/build-feed.tsas a subprocess against a tempSNAPSHOT_DIRholdinggenshin-fandom-events.htmland.meta.json, copied from a pinnedfixtures/genshin/articles/event-table-2026-11-06.html, plus anarticles/genshin-fandomcopy of the Hour of Ruin fixture. Add two environment overrides tobuild-feed.ts, each defaulting to today's behaviour:EVENT_FEED_OUTfor the output path, andEVENT_FEED_NOWpinned to2026-11-07T00:00:00.000Z, without which this test would fail once the fixture's events end. Assert Hour of Ruin'sregionEnds.europeis2026-12-15T13:59:00.000Z, itsidisgenshin:hour-of-ruin:2026-11-04, and the feed still passesEventFeed.parse. -
Step 2: Run:
bun test test/article-feed.test.ts. Expected: FAIL, row still at day precision. -
Step 3: Wire
build-feed.ts. ReadArticleStore.readEndsonce. PassarticleEndsin the context forgenshin-fandom-eventsonly. Keep that source's parsed rows. AftermergeEventsfor its game, applyapplyArticleEnds, 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, runbun run refresh --snapshots <scratch> --only genshin-fandom-events, then build from it withSNAPSHOT_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-guardianand fix what it finds. -
Step 8: Commit the feature paths as
feat(feed): show the end a Genshin article states, then the changelog separately aschore(changelog): note exact Genshin end times (<hash>), afterbun run scripts/check-changelog.ts.