From b03a6ed2d1d0e4524a3006228094fb7580179c62 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 1 Nov 2026 04:12:14 +0100 Subject: [PATCH] feat(reader): separate Miliastra and track passes as cycles Route source-classified activities into an independent Miliastra lane and show dated battle passes once under Cycles. Genshin's About tab can disable TCG tracking without deleting progress or history. Activate routing and presentation together so no intermediate version moves saved progress while still rendering its old owner. Preserve identities, clock revisions and deletion timestamps across late writes and backups. --- docs/2026-11-01-genshin-event-audit.md | 76 ++++++ docs/ARCHITECTURE.md | 12 + docs/DATA-MODEL.md | 37 +++ docs/DESIGN.md | 16 ++ docs/PRD.md | 25 ++ .../2026-11-01-content-classification.md | 107 +++++++++ .../specs/2026-10-16-pass-windows-design.md | 9 + ...026-11-01-content-classification-design.md | 225 ++++++++++++++++++ src/client/ReaderApp.tsx | 17 +- src/client/components/redesign/DailyPanel.tsx | 2 + .../components/redesign/GameSettings.tsx | 35 ++- .../components/redesign/PinnedRoutine.tsx | 14 +- .../components/redesign/RoutineDrawer.tsx | 8 +- .../components/redesign/RoutineProgress.tsx | 7 + .../components/redesign/RoutinesPage.tsx | 8 +- .../components/redesign/SetupWizard.tsx | 5 +- .../components/redesign/TimelinePage.tsx | 11 +- src/client/state/reader/accounts.ts | 2 + src/client/state/reader/contentRouting.ts | 189 +++++++++++++++ src/client/state/reader/eventRows.ts | 9 +- src/client/state/reader/import.ts | 101 +++++++- src/client/state/reader/migration.ts | 5 +- src/client/state/reader/onboarding.ts | 2 + src/client/state/reader/reducer.ts | 16 +- src/client/state/reader/routines.ts | 27 ++- src/client/state/reader/store.ts | 7 +- src/client/state/reader/updates.ts | 10 +- test/game-settings.test.tsx | 5 +- test/reader-content-routing.test.ts | 132 ++++++++++ test/reader-pass-cycles.test.ts | 34 +++ test/reader-tcg.test.tsx | 26 ++ 31 files changed, 1127 insertions(+), 52 deletions(-) create mode 100644 docs/2026-11-01-genshin-event-audit.md create mode 100644 docs/superpowers/plans/2026-11-01-content-classification.md create mode 100644 docs/superpowers/specs/2026-11-01-content-classification-design.md create mode 100644 src/client/state/reader/contentRouting.ts create mode 100644 test/reader-content-routing.test.ts create mode 100644 test/reader-pass-cycles.test.ts create mode 100644 test/reader-tcg.test.tsx diff --git a/docs/2026-11-01-genshin-event-audit.md b/docs/2026-11-01-genshin-event-audit.md new file mode 100644 index 0000000..932e455 --- /dev/null +++ b/docs/2026-11-01-genshin-event-audit.md @@ -0,0 +1,76 @@ +# Genshin event audit — owner walkthrough + +Recorded 2026-11-01. Research and owner confirmations only; no implementation or +deduplication design approved during the walkthrough. The owner subsequently +resumed work, chose source-driven classification, and requested a Genshin About +toggle for TCG events. The proposed implementation design is +`superpowers/specs/2026-11-01-content-classification-design.md`; approval of that +design was subsequently approved; implementation and verification are recorded +in that design and its linked plan. + +## Confirmed Genshin matches + +The owner read these from the game, confirming the following tracker matches. + +| In-game event | Tracker entry | Notes | +|---|---|---| +| Tabletop Troupe: A Gathering on Adventure's Eve | Free Limited 5-Star Anniversary Selector | Tartaglia, Nilou, Varesa and others; requires completing the new story. | +| Overflowing Favor | Overflowing Favor | Double rewards. | +| Carefree Snowball Fight | Carefree Snowball Fight | Same name. | +| Stygian Onslaught — Luminous Battlefront | Stygian Onslaught | Named season of the current run. | +| To Temper Thyself and Journey Far | To Temper Thyself and Journey Far | The other source calls this run Cycle 5. | +| Across the Frozen Wilds, Honing One's Edge | Free Standard 5-Star Anniversary Selector | Jean, Dehya, Mona and others; separate from the limited selector. | +| Test Run — Skirk, Escoffier, etc. | Test Run - Skirk, Escoffier, Dahlia, Candace, Mika | Trials change with the featured character banners. | +| The Godforsaken Frostlands | The Godforsaken Frostlands | New-region exploration rewards. | +| A Rekviem for the Underworld | A Rekviem for the Underworld | Archon Quest completion rewards; owner saw 2d 11h remaining. Separate from the selector unlocked by that quest. | +| The Forge Realm's Temper — Clever Stratagems | The Forge Realm's Temper: Clever Stratagems | TCG event; owner saw 2d 20h remaining. | + +Rainbow's End: Resplendent Starlight is **not confirmed**: the owner thinks it +probably appeared and was already claimed. Do not turn this into a confirmed +completion mark or use it to remove the event. + +The owner found the name **Moontrace** through Battle Pass → bottom-left icon, +with 1d 0h remaining. They want battle passes represented under cycles rather +than duplicated as event tasks. Inspect the existing pass/cycle implementation +before designing this change. Rounded remaining times above are observations, +not exact UTC boundaries; they must not be used to mint end timestamps. + +**Travelers' Tales: Snowstride Chapter** appears to the owner to be an update to +existing content rather than a timed event. No timed-event match was established. + +## Miliastra Wonderland separation + +The owner wants Miliastra Wonderland tracked separately from Genshin, potentially +as its own game/lane, and confirmed these belong there: + +- Wonderland Treasures: Moonlight After the Rain +- Starlit Gala +- Silvery Moonlight Chronicle +- Starlight Voyage: Heart Unbound +- Raiment Collection: Warmth of a Sunny Day + +Wonderland Treasures has no established one-to-one tracker match; it may be an +umbrella notice. Starlit Gala and Starlit Gala: Wonderland Selects have **not** +been confirmed as duplicates. Do not merge them based on their shared name. + +## Findings and remaining work + +The local built feed inspected during research was generated on 2026-10-22. +Parsing the cached sources already folds the two selector pairs, the Stygian +season and the To Temper cycle into their respective survivors. Moontrace's +Battle Pass prefix was addressed by the previous parser change. See +`superpowers/specs/2026-10-14-genshin-duplicates-design.md` and +`../src/ingest/merge.ts` for that work. + +This walkthrough established no additional duplicate pair requiring a new merge +rule. It identified content classification and potentially stale display issues: +Miliastra content under Genshin, pass placement, and older event rows. The older +Character Test Runs row covers a different banner phase from the current Skirk / +Escoffier trial, so those two runs must not be fused. + +Next session should inspect how the current client handles expiration, existing +cycle/pass windows, and the consequences of a separate Miliastra lane. Game and +event IDs are persisted identities: moving rows between games must preserve +reader progress and existing IDs or supply an explicit migration. Discuss +approaches before implementing; no lane split, date change, state migration or +broader matching rule has been implemented or approved by this audit. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db8c473..f61f5d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -417,3 +417,15 @@ progress through the live event editor. Settings keeps one shared tree for both separate default-view/custom-content pages and direct game profiles. Structured is the absent-value default at prepaint, preference normalization, setup and appearance application. No state keys, event identities or stored clock revisions changed for this presentation correction. + +### Classification at the reader boundary (2026-11-01) + +`src/shared/classification.ts` preserves source identity while resolving logical +content destinations, pass families and TCG categories. The wire feed retains +its original games/IDs. `state/reader/contentRouting.ts` normalizes classified +state under the existing lock/save path, with persisted origin/counterpart +mapping and timestamp rebasing. Source inventory is retained in the reader so +pass cycles work with ordinary Events disabled. Current inventory takes +precedence over remembered snapshots for live cycles; Archive snapshots keep +their historical dates and clock references. No new route, fetch, localStorage +key or server-side reader record is introduced. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 5a9c772..5b9d9c9 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -64,6 +64,8 @@ export const GachaEvent = z.object({ // Other sources' copies merge folded into this one; the client moves marks // off them. Optional — see § ID stability. mergedIds: z.array(z.string()).optional(), + // Additive source categories; game/ID still identify the original source row. + classification: EventClassification.optional(), }); export type GachaEvent = z.infer; @@ -852,3 +854,38 @@ both. The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`) for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved. + +## Source identity and content destinations (2026-11-01) + +`GachaEvent.classification` is optional: `{ lane?: "miliastra", activity?: +"battle-pass", family?: string, tags?: string[], unresolved?: boolean }`. +The wire `game` remains the source/identity game, so an existing Miliastra row +keeps `game: "genshin"` and its `genshin:...` ID. The current client resolves a +logical game for rendering and saved event snapshots. Older cached clients strip +the additive classification and still accept the feed; older feeds remain valid. +`unresolved` suppresses inferred routing after explicit source classifications +conflict. Reader-authored events do not enter this classifier. + +The v2 envelope also retains `contentEvents` (source event inventory, used by +feed-backed cycles) and `contentRoutes` (origin Genshin account ID → counterpart +Miliastra account ID). Account extensions `contentOrigin`, `contentPlaceholder`, +`passTrackingMigrated` and `legacyPassVisibility` record the routing/transition. +Newly offered, empty Miliastra placeholders may bind to their originating account; +configured or unrelated same-region accounts are never silently reused. Distinct +existing origins keep distinct counterparts. Unknown imported origins carrying +Miliastra records remain separate rather than selecting an account by region. + +Routing runs under the reader lock before saves. It moves classified progress, +ignores, daily/amount logs, unread state, seen and Archive snapshots; IDs, day +keys, clock revisions and archived resolved boundaries remain unchanged. It +rebases field timestamps while retaining origin timestamps, routes late actions +before conflict checks, and preserves deletion tombstones across older imports. +Import applies timestamps to routed days, amounts, ignores, progress fields and +Archive records, so a later clear/restore beats an older backup. Full restore +still replaces state intentionally; startup then normalizes that restored state. + +Pass family placement lives in `account.routines["feed-cycle::"]`; +progress/history uses the original event ID for each sourced window. A new +window inherits pins/following, never completion. No guessed fixed recurrence or +new task ID is stored. The game-wide `prefs.genshinTcg` defaults to true when +absent; false hides classified source TCG activities from live tracking only. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b33dc3a..7573e7e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -705,3 +705,19 @@ legend on the same baseline as "Next: …". setup. - Continue stays in a fixed place and Back sits beside it. Only a replay ("Replay the introduction" in Appearance) has a way out, "Close tour". + +## Content tracking additions (2026-11-01) + +Genshin's About tab has an Event tracking group before Data sources. The native +checkbox **Track TCG events** uses the existing settings row, with the copy +“Show Genius Invokation TCG events in your lists and timeline.” The setting is +saved immediately; while saving it is disabled, and failure keeps the prior +value with a visible status message. Nearby copy directs readers to Miliastra's +own settings. Both interfaces and themes use the same control and tokens. + +Miliastra Wonderland is a separate lane, short label Miliastra, with hue +`#9C83D6`; normal theme contrast resolution applies. Source-backed passes use the +existing cycle library, progress checkbox, independent pin controls and timeline +bands. Their boundary captions say “ends”, not “resets”: the next pass is only +known when a source dates it. Cycle group headers use “Next deadline in” to +cover passes and resetting routines together. diff --git a/docs/PRD.md b/docs/PRD.md index 4544f82..60b03e7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -671,3 +671,28 @@ product exists to prevent. (Assumption: no — the filter is global.) - Is 6 hours the right refresh cadence? (Assumption: yes; events are announced days ahead, so sub-hourly refresh buys nothing and is rude to the sources.) + +### Content destinations, pass cycles and TCG tracking (2026-11-01) + +Miliastra Wonderland is independently selectable from Genshin, with its own +focus, visibility and lane order. It arrives off for existing readers like other +new lanes, and is available in new-reader setup. Existing classified progress and +history follow it without changing event IDs. It shares its supplying Genshin +sources and server/reset defaults; no new standing daily task is invented. + +Source-dated battle-pass windows appear in Routines under Cycles, with independent +Checklist and Timeline pins. They keep their source deadlines and per-window +completion; the next sourced pass inherits following/pins. They do not also +appear as ordinary event-list rows. Followed Checklist passes participate in +next-deadline guidance; timeline-only passes do not. Weekly pass EXP is separate. +This supersedes the ordinary-event presentation chosen in the 2026-10-16 pass +windows spec, without changing its source/date requirements. On transition, +existing event followers retain pass-family pins in enabled lanes; newly offered +Miliastra remains hidden until enabled. + +Genshin → About → Event tracking provides **Track TCG events**, on by default, +across that reader's Genshin servers. Off hides classified source TCG activities +from live lists, timeline, counts, next-deadline guidance and event-derived daily +instructions. It preserves notes, completion and Archive history; turning it +back on restores eligible unfinished content. Failed saves retain the prior +setting. The control does not classify reader-authored events. diff --git a/docs/superpowers/plans/2026-11-01-content-classification.md b/docs/superpowers/plans/2026-11-01-content-classification.md new file mode 100644 index 0000000..bf81526 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-content-classification.md @@ -0,0 +1,107 @@ +# Content Classification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Separate Miliastra, represent sourced passes as cycles, and let readers disable Genshin TCG tracking without losing state. + +**Architecture:** Preserve source/identity game and IDs in the feed; optional classification metadata supplies the destination lane, battle-pass family and TCG tag. A shared resolver handles old feeds and saved snapshots. Reader normalization routes identified Miliastra state atomically and preserves account clocks; pass presentation reuses event progress rather than creating another task store. + +**Tech Stack:** Bun, TypeScript, React 19, Zod; existing dependencies only. + +**Spec:** `docs/superpowers/specs/2026-11-01-content-classification-design.md` + +## Global Constraints + +- Work on main in this checkout per AGENTS.md. No commits or pushes without request. +- Keep every event ID, date, source ID and source URL unchanged. +- Preserve old feed validation: classification is additive; wire game stays the identity game. +- Miliastra arrives off; TCG defaults on across Genshin accounts. +- No guessed recurrence, dates, classification or completion; reader-created content is excluded. + +## Review Focus + +- Legacy imports/late writes after a move must honor destination deletion timestamps. +- Multiple accounts with equal regions must retain distinct ownership and clock history. +- Old cached feeds and remembered snapshots must classify consistently without causing startup failure. +- Pass-only tracking and independent pins must not create duplicate rows or unrelated cycle follows. +- Hidden TCG must leave history intact and disappear from all live instructions, including event dailies. + +### Task 1: Shared classification and parser evidence + +**Files:** `src/shared/schema.ts`, `src/shared/classification.ts` (new), `src/shared/games.ts`, `src/ingest/parsers/{fandom,fandomTable,versionTable}.ts`, `src/ingest/merge.ts`, `test/classification.test.ts` (new), affected adapter expectations. + +**Interfaces:** `classificationFor(event): EventClassification`, `displayGameFor(event): string`, `passFamilyFor(event): string | null`, `isTcgEvent(event): boolean`. Metadata is optional `{ lane?: "miliastra", activity?: "battle-pass", family?: string, tags?: string[] }`. Recognize established article families and source registrations for cached feeds; parser reads stated type labels. Register `miliastra` for current clients but keep existing wire game fields unchanged. + +- [x] Write tests for source categories, cached source paths, custom exclusion, source winner lacking metadata, and unknown fallback; run and observe failure. +- [x] Add schema/resolver, parser labels and merge retention; preserve fixture IDs and dates. Cite reviewed TCG family evidence. +- [x] Run `bun test test/classification.test.ts test/merge.test.ts test/adapters/game8.test.ts test/adapters/fandomTable.test.ts test/adapters/versionTable.test.ts`; regenerate only intentionally changed expectation fields. + +### Task 2: Atomic routing and compatibility + +**Files:** `src/client/state/reader/contentRouting.ts` (new), `store.ts`, `reducer.ts`, `export.ts`, `schema.ts` as needed; `test/reader-content-routing.test.ts` (new). + +**Interfaces:** `routeReaderContent(state: ReaderState): ReaderState` is pure and idempotent, driven by the shared classifier. A persisted `contentRoutes` record maps original accounts to Miliastra counterparts. Counterpart identity is derived deterministically from the existing account ID; it is not a reminted identity. Copy original clock revisions and unknown account fields, preserve retired status, and append Miliastra to known games while hiding it on first introduction. + +Rebase account-scoped `fieldTimes` paths for classified IDs, preserving the latest timestamp at either path and retaining origin tombstones. Route incoming old-account actions before conflict checks. Destination values win timestamp ties; progress fields merge by latest field time, day logs by union subject to day tombstones, amounts by period timestamp, ignores by saved timestamps. Archive/seen records keep original clock/resolved end and event ID but use logical game Miliastra. Do not reuse an unrelated same-region account. Normalize startup, dispatch and imports/restores so old-tab/backup deltas route consistently. No source-state transfer is repeated after clearing a mark. + +- [x] Add failing tests for two same-region origins, clock revisions, all record collections, idempotence, source/destination clears, late actions and import/restore. +- [x] Implement pure normalization and wire locked save/import paths; create empty destination data only for actual moved state or explicit account use. +- [x] Run `bun test test/reader-content-routing.test.ts` and existing reader store/export/migration tests; expect no state validation failures. + +### Task 3: Classified reader rows and Miliastra settings + +**Files:** `src/client/state/reader/{eventRows,routines,migration,onboarding}.ts`, `src/client/ReaderApp.tsx`, `src/client/components/redesign/{GameSettings,SetupWizard}.tsx`, `src/shared/feed.ts`, tests for reader rows and views. + +**Interfaces:** Reader event rows use logical destination game and existing IDs. Shared category filtering excludes TCG when `prefs.genshinTcg === false`. Retain classified snapshots for migration and source-backed pass rows even when ordinary Events is off. Freshness/source lookup maps logical Miliastra to supplying Genshin sources. + +- [x] Add failing tests for lane independence, hidden-lane policy, cached snapshots, source attribution and unchanged Genshin rows. +- [x] Route row rendering and lane discovery; offer Miliastra independently without synthetic dailies; point to its settings from Genshin About. +- [x] Run focused reader/view tests and typecheck. + +### Task 4: Feed-backed battle-pass cycles + +**Files:** `src/client/state/reader/{routines,contentRouting}.ts`, `src/client/components/redesign/{RoutinesPage,RoutineDrawer,RoutineProgress,TimelinePage,GameSettings,SetupWizard}.tsx`, `src/client/ReaderApp.tsx`, `test/reader-pass-cycles.test.ts` (new). + +**Interfaces:** Feed cycle rows carry `event` and a stable family placement key while retaining per-window event ID for progress. Family placements are persisted in `account.routines`; next window inherits pins, not completion. Existing event followers gain pass-family placements once, without following unrelated routines. New accounts derive pass following from Cycles intent. Event-backed routine controls dispatch existing event outcome/progress actions. + +- [x] Add failing tests for old-reader transition, pass-only tracking, next-window completion isolation, independent pins, Miliastra pass off until lane enabled, and no second ordinary-event row. +- [x] Implement family placements and dynamic rows using sourced event clocks; extend library, progress controls, checklist guidance and timeline windows. Never render a predicted recurrence/reset. +- [x] Run `bun test test/reader-pass-cycles.test.ts` plus affected routine/timeline tests and typecheck. + +### Task 5: Genshin TCG switch + +**Files:** `src/client/components/redesign/GameSettings.tsx`, `src/client/state/reader/eventRows.ts`, `routines.ts`, `test/reader-tcg.test.tsx` (new). + +**Interfaces:** `prefs.genshinTcg` absent means true. Genshin About renders Track TCG events, supporting copy from spec, immediate save and failure rollback. All live surfaces use the shared reader classification predicate; history remains readable. + +- [x] Add failing tests for default/on/off/cached snapshot behavior, multiple accounts, saved-history preservation and rejected save. +- [x] Implement existing-style accessible toggle and shared filtering; no ignored/completed writes. +- [x] Run focused TCG and reader row tests. + +### Task 6: Verification and documentation + +**Files:** PRD, DATA-MODEL, INGESTION, DESIGN, ARCHITECTURE where changed; prior pass spec and audit; focused expiration regressions; browser scratch files only under /tmp. + +- [x] Verify audited ended trials cannot re-enter live rows through remembered state; verify regional day-precision ends without changing stored timestamps. Test existing behavior before proposing repairs. +- [x] Compare parsed/built before/after IDs and dates offline. Update docs for implemented classification, routing and cycle behavior; record any plan rulings in the ledger. +- [x] Run `bun run typecheck`, full `bun test`, `git diff --check`. Build only css/js/static shell; use a separately generated temporary feed if needed for deterministic browser data. +- [x] Browser-check desktop/mobile, both themes, ordinary HTTP hostname startup/writes, old-state migration, TCG off/reload/on, pass-only and Miliastra isolation. Run Impeccable detector once over changed UI files. +- [x] Fresh-context final review; fix material findings with regression tests; report checks and any remaining limitations. Leave work uncommitted per AGENTS.md. + +## Verification record + +Completed 2026-11-01. Full suite: 1748 pass, 0 fail; typecheck and diff check pass. +The baseline required HTTP test binding outside the sandbox. Browser checks used +Chrome on an ordinary HTTP hostname at fresh build origins: migration, TCG +off/reload/failure/on, pass completion/undo, Miliastra enabling and retained pass +following, new-reader startup, and desktop/mobile dark/light layouts with no page +errors or horizontal overflow. Impeccable detector returned no findings. +Offline comparison against HEAD covered 25 sources / 197 parsed rows and every +merged game: IDs, dates, titles and source identities unchanged; only category +metadata changed. The old feed schema accepts the new parsed output. +Fresh code review produced five material findings, all fixed with failing-then- +passing regressions (backup tombstones, legacy ignores, late-created pass +following, inventory precedence and unrelated imported origins). Additional +regressions cover the new-reader placeholder and already-routed import mapping. +Implemented on main. The follow-up changelog commit records the implementation +range under the repository convention. diff --git a/docs/superpowers/specs/2026-10-16-pass-windows-design.md b/docs/superpowers/specs/2026-10-16-pass-windows-design.md index baf0541..7d31f18 100644 --- a/docs/superpowers/specs/2026-10-16-pass-windows-design.md +++ b/docs/superpowers/specs/2026-10-16-pass-windows-design.md @@ -139,3 +139,12 @@ The plan settles module boundaries after dumping each page's structure; `fandom. `docs/SOURCES.md` (a section per source), `docs/INGESTION.md` (the two templates), AGENTS.md's parser list where it counts parsers, TODO.md (sub-project 3 done; the not-shipped three as follow-ups), a changelog entry in a follow-up commit. + +## Presentation superseded (2026-11-01) + +The owner approved source-backed pass windows appearing under Cycles rather +than as duplicate ordinary events. The new implementation retains the existing +feed event IDs, dates and sources and reuses their progress, with pass-family +follow/pin preferences. It does not infer a recurrence. See +`2026-11-01-content-classification-design.md`; the source/date decisions above +remain in force. diff --git a/docs/superpowers/specs/2026-11-01-content-classification-design.md b/docs/superpowers/specs/2026-11-01-content-classification-design.md new file mode 100644 index 0000000..732c024 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-content-classification-design.md @@ -0,0 +1,225 @@ +# Source classifications, Miliastra and battle-pass cycles + +Date: 2026-11-01. Status: approved by the owner; implemented and verified. + +## Intent and evidence + +The owner reported Genshin duplicates, then compared the tracker against the +game. The walkthrough is recorded in `../../2026-11-01-genshin-event-audit.md`. +It confirmed ten timed Genshin activities, one probably claimed anniversary +reward, Moontrace as a battle pass, and five named Miliastra items. It established +no additional duplicate pair requiring broader fuzzy matching. + +The cached Genshin Fandom table already states: + +- Moontrace: `In-Game, Battle Pass`. +- Silvery Moonlight Chronicle: `In-Game, Miliastra Pass, Miliastra Wonderland`. +- Raiment Collection, Starlit Gala: Wonderland Selects and Starlight Voyage: + `In-Game, Miliastra Wonderland`. +- Starlit Gala: `Web, Miliastra Wonderland`. + +The parser currently reduces these labels to `EventType` and loses the useful +distinctions. Cross-source merge already folds the selector, Stygian and +To Temper pairs. ReaderApp already filters ended rows and reconciles previously +seen ones into Archive: old rows in the feed do not establish a display bug. + +The owner chose source-driven classification over a maintained title table, and +requested a show/track TCG toggle in Genshin's About tab. + +## Approaches + +1. **Preserve classifications from sources (selected).** Labels route new + Miliastra activities and passes without editing a list every patch. Costs an + additive feed field, shared classification rules and progress-safe routing. +2. **A title-to-category table.** Smaller initially, but new poetic titles need + continual upkeep and unknown titles silently return to the wrong section. + +Use reviewed family/link mappings only where the source's category vocabulary +needs interpretation. They are deterministic, evidenced rules, never a fuzzy +title classifier or a replacement for source labels. + +## Goals, boundaries and constraints + +- Miliastra Wonderland is an independently selectable built-in lane. +- Source-dated battle passes are tracked through Cycles, once per pass window. +- Genshin's About tab controls whether its TCG events are tracked. +- Notes, completion, ignores, daily logs, amounts, history and clock references + survive reclassification, import/restore and repeated application. +- Keep IDs, source URLs, source IDs, dates, precision and the current matching + threshold unchanged. No additional fetches or dependencies are needed. +- Reader-created games, events and routines are not classified automatically. +- Do not invent schedules for missing content or create a row for an umbrella + notice. Wonderland Treasures: Moonlight After the Rain remains unpaired. +- Do not merge Starlit Gala and Wonderland Selects. Their source classifies one + as Web and the other as In-Game; similar names do not establish duplicate work. +- Do not turn the owner's rounded countdowns into precise timestamps, or their + probable Rainbow's End claim into completion state. + +## Classification and compatibility + +Add optional structured classification to `GachaEvent`: destination lane, +battle-pass activity, and TCG tag. Exact property names are the builder's call; +the distinctions and compatibility below are required. The original `game` +remains the source/identity game in the wire feed. For example a Miliastra row +continues to carry `game: genshin` and its existing `genshin:...` ID, with an +additive destination of `miliastra`. + +This separation is deliberate: changing the wire `game` to a new enum value +would cause old cached clients to reject the entire feed. Old clients strip +optional metadata and continue their previous behavior; new clients resolve a +logical destination for rendering and tracking. Old cached feeds still parse. +Register Miliastra's metadata for the new client without emitting a new game +value in old wire fields. Do not alter `slugify` or `eventId`. + +Parsers preserve stated classifications, including established pass-only source +registrations (Nameless Honor, City Fund and Mira Journey). Merge retains +classification supplied by either matched source even when the identity winner +lacks it; date and title selection do not change. Explicit incompatible +classifications are reported and left unresolved rather than guessed. Absent +classification is not evidence against a stated classification. + +For older feeds and saved event snapshots, a shared deterministic resolver may +recognize established source article paths and the owner-confirmed families. +Keep each rule's evidence beside it. Unknown content stays in its original lane +and ordinary-event category. The new parser metadata is the ongoing mechanism; +the compatibility rules protect cached/offline readers and existing history. + +## Miliastra lane and saved state + +Miliastra can be enabled, hidden, focused and ordered separately from Genshin. +It uses Genshin's established server/reset defaults; do not invent a different +clock. No standing daily task or new Miliastra routine is invented. Sources and +freshness reflect the same underlying Genshin source(s) that supply its rows; +there is no second request and no claim of a fresh independent source. + +Like other newly added lanes, it arrives off for existing readers and is +available in setup for new readers. This lane setting takes precedence over +preserving a relocated pass's visibility: Silvery Moonlight Chronicle stays +hidden until Miliastra is enabled, with its follow/pin preferences and progress +retained. Enabling the lane restores its eligible placements. Genshin's About tab includes a short pointer +to Miliastra's settings so the relocated activities are discoverable. + +Reclassification resolves storage as well as presentation. Use a persisted +origin-to-destination account mapping, one Miliastra counterpart for each +Genshin account whose identified Miliastra state must move. Preserve region and +clock history when establishing that counterpart, including historical revision +IDs referenced by logs; do not relabel day keys or remint event IDs. Distinct +origin accounts remain distinct even when their current region matches. +Mapped counterparts subsequently have independent settings and clocks. + +Only classified source-authored Miliastra records move: progress, ignores, +daily/amount logs, seen/unread state, seen snapshots and Archive records. Leave +unrelated Genshin data and reader-created content alone. Historical archived +snapshots retain the meaning of their saved clocks and resolved boundaries. +Retired origins with moved history get a retired counterpart, not a new active +subscription. Empty counterparts need not be created until tracking is enabled. + +The routing operation runs through the reader's lock/reducer/save path and is +idempotent. A failed save changes no visible ownership. Carry field timestamps +and deletion tombstones so reopening, old-tab writes, importing old backups or +clearing a migrated mark cannot resurrect data. Transfers are moves, not copies +repeated on every load. Export/import and restore retain the account mapping; +newly encountered legacy records for a mapped activity use that mapping. Never +route an old import into an arbitrary same-region account. The implementation +plan must spell out field-path rebasing and collision rules before coding. + +## Pass windows in Cycles + +This supersedes the ordinary-event presentation chosen in the 2026-10-16 +pass-windows design; its date discipline and source coverage still apply. + +Represent a pass as a feed-backed cycle window, preserving the event's existing +ID and account-scoped progress. Its sourced start/end and precision govern its +deadline. Do not express it as a fixed 42-day recurrence or reset it from a +version estimate. A new run is another sourced window, never an inferred one. + +Passes are available in Routines under Cycles and follow existing independent +Checklist/Timeline placement controls. They can participate in next-deadline +guidance when followed and pinned to Checklist; a timeline-only pin does not +create a Checklist instruction. An ordinary event row for that same pass must +not render as well. Done/ignored/notes/history still use the existing pass ID; +there is no duplicate task ID or second progress store. + +Follow/pin preferences belong to the pass family per account, so the next sourced +window inherits the choice while each window retains its own progress. Weekly +Battle Pass EXP remains a separate weekly routine. For existing readers who +track events, preserve pass visibility in enabled lanes by following/pinning +their existing pass family during the transition, without enabling other cycle +routines. Hidden lanes remain hidden, including newly offered Miliastra. New +readers follow passes through Cycles. An account can follow passes with ordinary +Events turned off. Silvery Moonlight Chronicle is a pass under Miliastra. + +## Genshin About: TCG switch + +Use the existing settings control style, with label **Track TCG events** and +supporting copy **Show Genius Invokation TCG events in your lists and timeline.** +This is a game-wide preference across the reader's Genshin servers; other +tracking controls can remain per account. Absent means on, preserving existing +coverage. Save immediately through the existing prefs action; display save +failure and restore the saved switch position when persistence fails. + +Off removes classified TCG events from live Checklist, Timeline, next-deadline +guidance, focus counts and event-derived daily/check-in instructions. It does +not mark them ignored/done, archive them merely for being hidden, delete data, +or erase existing history. On restores eligible unfinished rows with all their +progress. It is a tracking preference, not an Archive filter; existing history +stays readable. No generic event-type filter or Miliastra setting changes. + +Recognize TCG from explicit source categories, or an evidenced mapping from the +source's named TCG families/article paths. The owner's Forge Realm confirmation +is one such record. Broad words such as card, battle or challenge are not enough. +Apply the same resolver to saved snapshots so a cached copy cannot bypass the +switch. Reader-authored content is unaffected. + +## Expiration and remaining deduplication + +Reproduce the audited old rows through the current client, including remembered +seen-event snapshots and regional reset boundaries. Verify they are absent from +live guidance after their resolved ends and that previously seen events retain +history. Repair a demonstrated failure only; don't drop unknown-end events, +retire on raw UTC-midnight placeholders or remove historical records. + +Keep current cross-source matching and `mergedIds` carry-forward. Add regression +coverage for the confirmed selector pairs and differently named seasons, and +for separate Starlit Gala activities and consecutive character trials. A +classification change does not itself establish event equivalence. + +## Validation and delivery + +Plan implementation in independently verifiable parts: additive classification +and merge; progress-safe lane routing; feed-backed pass cycles; TCG control; +expiration verification and documentation. State protection ships before its +dependent presentation changes. No commits or pushes unless asked. + +Required checks include: + +- Fixture parsing preserves all existing IDs/dates and extracts the stated labels; + the identity winner retains the loser's classification. +- Old feeds remain readable; every wire game value remains accepted by the old + feed contract. Unknown classifications preserve ordinary-event fallback. +- Multi-account lane routing preserves clocks, progress, logs, notes, history, + tombstones and unknown fields across reload, import/restore and late writes; + repeated routing and failed saves do not duplicate or lose records. +- Pass completion survives relocation; future sourced windows inherit follow + and pins, not completion; Events off / Cycles on works; no duplicate row. +- TCG on/off/reload/failed-save behavior affects every live surface, preserves + history and applies to remembered source rows, with independent Genshin servers. +- Browser checks at desktop/mobile sizes, both themes, and an ordinary HTTP + hostname cover startup, migration and writes. Use fresh build origins per + AGENTS.md. Shell-only builds must not refresh source snapshots. +- Appropriate targeted tests, full suite and typecheck. Feed before/after diff + accounts for every changed field and confirms no ID or date moved. + +Update PRD, DATA-MODEL, INGESTION, DESIGN and relevant prior specs when their +behavior changes; record the pass-window decision's supersession and source +identity versus destination-lane semantics. Reader-visible changes receive the +repository's changelog treatment when committed. + +## Proposed defaults to review + +The owner chose source-driven routing and requested the TCG control. The +following are design proposals: TCG defaults on and applies across Genshin +servers; Miliastra arrives off like other new lanes; existing followed pass +visibility in enabled lanes is preserved during its transition to Cycles. A fresh +builder review found the conflict between this default and Miliastra arriving +off; the proposal above explicitly gives the lane setting precedence. The owner approved these defaults before implementation. diff --git a/src/client/ReaderApp.tsx b/src/client/ReaderApp.tsx index 93dd18d..a3f18fc 100644 --- a/src/client/ReaderApp.tsx +++ b/src/client/ReaderApp.tsx @@ -37,6 +37,7 @@ import { type ReaderEventRow, } from "./state/reader/eventRows.ts"; import { routineRows } from "./state/reader/routines.ts"; +import { displayGameFor, passPlacementId, sourceGameFor } from "../shared/classification.ts"; import { setupReader } from "./state/reader/onboarding.ts"; import { resolveReaderClock, readerClockFor } from "../shared/readerClock.ts"; import { @@ -144,6 +145,7 @@ export function ReaderApp() { ...new Set([ ...feed.sources.map((s) => s.game), ...feed.events.map((e) => e.game), + "miliastra", ]), ], new Date().toISOString(), @@ -159,6 +161,7 @@ export function ReaderApp() { return; } const created = createReaderStore(localStorage, opened.state); + await created.dispatch({ type: "content", events: feed.events, ...actionMeta() }); setStore(created); setFeed({ status: "ready", feed }); }) @@ -398,15 +401,19 @@ function ReaderContent({ void dispatch({ type: "aliases", table, ...actionMeta() }); }, [feed.events, state.data, state.archive, state.seenEvents, dispatch]); const all = useMemo( - () => readerEventRows(state, feed.events, now), + () => readerEventRows(state, feed.events, now, true), [ state.accounts, state.customEvents, state.seenEvents, + state.prefs.genshinTcg, feed.events, Math.floor(now / 60000), ], ); + useEffect(() => { + void dispatch({ type: "content", events: feed.events, ...actionMeta() }); + }, [feed.events, dispatch]); useEffect(() => { if (!prefs.onboarded) return; const records = all @@ -439,7 +446,7 @@ function ReaderContent({ .filter((a) => state.eventSeen[a.id] === undefined) .map((a) => ({ id: a.id, - ids: feed.events.filter((e) => e.game === a.game).map((e) => e.id), + ids: feed.events.filter((e) => displayGameFor(e) === a.game).map((e) => e.id), })); if (entries.length) void dispatch({ type: "seenBatch", entries, ...actionMeta() }); @@ -547,6 +554,7 @@ function ReaderContent({ const outstanding = all.filter( (r) => !r.clock.ended && + (!passPlacementId(r.event) || state.accounts[r.accountId]?.routines[passPlacementId(r.event)!]?.checklist) && !state.archive[pairKey(r.accountId, r.event.id)] && progress(r).status !== "done" && !state.data[r.accountId]?.ignored[r.event.id], @@ -568,6 +576,7 @@ function ReaderContent({ const visible = all.filter( (r) => (focus === null || r.event.game === focus) && + !passPlacementId(r.event) && !r.clock.ended && (!r.clock.upcoming || prefs.showUpcoming) && (!state.archive[pairKey(r.accountId, r.event.id)] || @@ -1059,7 +1068,7 @@ function ReaderContent({ ) : settingsPage === "sources" ? ( - trackedGames(state).has(s.game), + trackedGames(state).has(s.game) || (s.game === "genshin" && trackedGames(state).has("miliastra")), )} now={now} timezone={zone} @@ -1369,7 +1378,7 @@ function ReaderDetail({ }); const own = recordFor(ownProps.events, row.event.id); const stale = freshness( - sources.filter((s) => s.game === row.event.game), + sources.filter((s) => s.game === sourceGameFor(row.event.game)), now, ).stale; return ( diff --git a/src/client/components/redesign/DailyPanel.tsx b/src/client/components/redesign/DailyPanel.tsx index 875d7da..61676ba 100644 --- a/src/client/components/redesign/DailyPanel.tsx +++ b/src/client/components/redesign/DailyPanel.tsx @@ -17,6 +17,7 @@ import type { ReaderState } from "../../state/reader/schema.ts"; import type { RoutineRow } from "../../state/reader/routines.ts"; import { actionMeta } from "../../state/reader/actions.ts"; import type { ActionHandler } from "./GameSettings.tsx"; +import { RoutineProgress } from "./RoutineProgress.tsx"; export function RoutineControl({ row, onAction, @@ -36,6 +37,7 @@ export function RoutineControl({ useEffect(() => { if (!editing.current) setAmount(String(row.status.count)); }, [row.status.count, row.status.periodStart]); + if (row.event) return
{row.title}
; return (
{burst > 0 && } diff --git a/src/client/components/redesign/GameSettings.tsx b/src/client/components/redesign/GameSettings.tsx index eb251a5..8ff6ef4 100644 --- a/src/client/components/redesign/GameSettings.tsx +++ b/src/client/components/redesign/GameSettings.tsx @@ -30,6 +30,8 @@ import { resolveReaderClock } from "../../../shared/readerClock.ts"; import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts"; import { VERSIONS, versionWindow } from "../../../shared/versions.ts"; import { cadenceOf } from "../../../shared/period.ts"; +import { sourceGameFor } from "../../../shared/classification.ts"; +import { passFamiliesForGame } from "../../state/reader/contentRouting.ts"; export type ActionHandler = (action: ReaderAction) => Promise; export const GAME_TABS = ["setup", "codes", "about"] as const; export type GameTab = (typeof GAME_TABS)[number]; @@ -89,6 +91,7 @@ export function GameSettings({ const [addingServer, setAddingServer] = useState(false); const [server, setServer] = useState("asia"); const [message, setMessage] = useState(""); + const [savingTcg, setSavingTcg] = useState(false); const accounts = Object.values(state.accounts).filter( (a) => a.game === selected && !a.retiredAt, ); @@ -214,14 +217,33 @@ export function GameSettings({
)} + {selected === "genshin" && ( +
+

Event tracking

+ +

Show Genius Invokation TCG events in your lists and timeline.

+

Miliastra Wonderland has its own game settings. Enable it in Your games to track its activities separately.

+ {message &&

{message}

} +
+ )}

Data sources

- {sources.filter((s) => s.game === selected).length === 0 ? ( + {sources.filter((s) => s.game === sourceGameFor(selected)).length === 0 ? (

No built-in source for this game.

) : ( <> {sources - .filter((s) => s.game === selected) + .filter((s) => s.game === sourceGameFor(selected)) .map((src) => (

@@ -241,7 +263,7 @@ export function GameSettings({ {[ ...new Set( sources - .filter((s) => s.game === selected) + .filter((s) => s.game === sourceGameFor(selected)) .map((src) => hostOf(src.url)), ), ].join(" and ")}{" "} @@ -626,9 +648,9 @@ function AccountSetup({ const [draft, setDraft] = useState(a.intent); const builtins = routinesFor(a.game); const availability = { - dailies: !a.game.startsWith("mygame:"), + dailies: !a.game.startsWith("mygame:") && a.game !== "miliastra", weeklyMonthly: builtins.some((r) => cadenceOf(r.period) !== "cycle"), - cycles: builtins.some((r) => cadenceOf(r.period) === "cycle"), + cycles: builtins.some((r) => cadenceOf(r.period) === "cycle") || passFamiliesForGame(a.game).length > 0, }; /** * Saved on each click. Only the kind that changed touches its routines, so @@ -648,6 +670,7 @@ function AccountSetup({ placements[id] = { followed, checklist: followed, timeline: followed }; }; if (changed.includes("dailies")) place(`dailies:${a.game}`, next.dailies); + if (changed.includes("cycles")) for (const id of passFamiliesForGame(a.game)) place(id, next.cycles); for (const r of builtins) { const kind = cadenceOf(r.period) === "cycle" ? "cycles" : "weeklyMonthly"; if (changed.includes(kind)) place(r.id, next[kind]); @@ -694,7 +717,7 @@ function AccountSetup({ "Routines on a game-specific schedule, such as a 14-day endgame mode", ids: builtins .filter((r) => cadenceOf(r.period) === "cycle") - .map((r) => r.id), + .map((r) => r.id).concat(passFamiliesForGame(a.game)), }, ] as const ).map((k) => { diff --git a/src/client/components/redesign/PinnedRoutine.tsx b/src/client/components/redesign/PinnedRoutine.tsx index da1f0f1..cd2a00b 100644 --- a/src/client/components/redesign/PinnedRoutine.tsx +++ b/src/client/components/redesign/PinnedRoutine.tsx @@ -106,6 +106,14 @@ export function PinnedRoutine({ }); }; const toggle = () => { + if (row.event) { + acted.current = true; + finishesGroup.current = finishes; + void onAction(met + ? { type: "restore", accountId: row.accountId, eventId: row.id, ...actionMeta() } + : { type: "outcome", outcome: "done", accountId: row.accountId, eventId: row.id, ...actionMeta() }); + return; + } if (amount) { if (!met) { setBefore(count); @@ -131,7 +139,7 @@ export function PinnedRoutine({ const span = routinePeriod(row, now); const resets = span.estimated ? estimatedReset(row, now) - : `resets in ${formatRemaining(span.end - now)}`; + : `${row.event ? "ends" : "resets"} in ${formatRemaining(span.end - now)}`; const progress = amount ? `${count.toLocaleString()} / ${required.toLocaleString()}${unit ? ` ${unit}` : ""}` : required > 1 @@ -209,9 +217,9 @@ export function PinnedRoutine({ ? `${row.title}, done ${lower(period)}. Undo` : amount ? `Mark ${row.title} complete` - : `${row.title}, not done ${lower(period)}. Tick today` + : row.event ? `Mark ${row.title} complete` : `${row.title}, not done ${lower(period)}. Tick today` } - title={met ? "Undo" : amount ? "Mark complete" : "Tick today"} + title={met ? "Undo" : amount || row.event ? "Mark complete" : "Tick today"} onClick={toggle} > + ; if (row.status.measure !== "amount") { const today = readerDayKey(now, row.clock), done = row.days.includes(today), diff --git a/src/client/components/redesign/RoutinesPage.tsx b/src/client/components/redesign/RoutinesPage.tsx index a50771a..9859aa1 100644 --- a/src/client/components/redesign/RoutinesPage.tsx +++ b/src/client/components/redesign/RoutinesPage.tsx @@ -39,7 +39,7 @@ export function placementAccounts( ...account, routines: { ...account.routines, - [row.id]: { ...row.placement, [key]: value }, + [row.placementId ?? row.id]: { ...row.placement, [key]: value }, }, }); } @@ -309,7 +309,7 @@ export function RoutinesPage({ ...a, routines: { ...a.routines, - [row.id]: { ...row.placement, [key]: value }, + [row.placementId ?? row.id]: { ...row.placement, [key]: value }, }, }, ...actionMeta(), @@ -362,7 +362,7 @@ export function RoutinesPage({

- Next reset in + Next deadline in {(() => { // An estimated end is not a reset (spec 2026-10-22). @@ -441,7 +441,7 @@ export function RoutinesPage({ ? "Weekly" : row.cadence === "month" ? "Monthly" - : "Game cycle"} + : row.event ? "Battle pass" : "Game cycle"} )} diff --git a/src/client/components/redesign/SetupWizard.tsx b/src/client/components/redesign/SetupWizard.tsx index 4a731e7..b1a573b 100644 --- a/src/client/components/redesign/SetupWizard.tsx +++ b/src/client/components/redesign/SetupWizard.tsx @@ -1,3 +1,4 @@ +import { passFamiliesForGame } from "../../state/reader/contentRouting.ts"; import { useState, type CSSProperties, type ReactNode } from "react"; import { TimezoneSelect } from "./TimezoneSelect.tsx"; import { ViewCards } from "./ViewCards.tsx"; @@ -84,8 +85,8 @@ export function SetupWizard({ k === "events" ? 0 : k === "dailies" - ? 1 - : routinesFor(g).filter( + ? (g === "miliastra" ? 0 : 1) + : (k === "cycles" ? passFamiliesForGame(g).length : 0) + routinesFor(g).filter( (r) => offered(r, standard) && (cadenceOf(r.period) === "cycle") === (k === "cycles"), diff --git a/src/client/components/redesign/TimelinePage.tsx b/src/client/components/redesign/TimelinePage.tsx index eca130a..a212a1c 100644 --- a/src/client/components/redesign/TimelinePage.tsx +++ b/src/client/components/redesign/TimelinePage.tsx @@ -54,6 +54,7 @@ export function routinePeriod( row: RoutineRow, now: number, ): { start: number; end: number; estimated: boolean } { + 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") { @@ -301,8 +302,8 @@ export function TimelinePage({ {period.estimated ? estimatedReset(r, now) : showEnd - ? `resets ${formatInZone(period.end, true, zone)}` - : `${formatRemaining(period.end - now)} to reset`} + ? `${r.event ? "ends" : "resets"} ${formatInZone(period.end, true, zone)}` + : `${formatRemaining(period.end - now)} to ${r.event ? "end" : "reset"}`} {!period.estimated && ( @@ -410,8 +411,8 @@ export function TimelinePage({ label: period.estimated ? estimatedReset(r, now) : showEnd - ? `resets ${day(period.end)}` - : `${formatRemaining(period.end - now)} to reset`, + ? `${r.event ? "ends" : "resets"} ${day(period.end)}` + : `${formatRemaining(period.end - now)} to ${r.event ? "end" : "reset"}`, onOpen: () => setRoutine(r), }; })} @@ -571,7 +572,7 @@ function RoutineDetail({ estimatedReset(row, now).replace(/^r/, "R") ) : ( <> - Resets {formatInZone(period.end, true, zone)} · in{" "} + {row.event ? "Ends" : "Resets"} {formatInZone(period.end, true, zone)} · in{" "} {formatRemaining(period.end - now)} )} diff --git a/src/client/state/reader/accounts.ts b/src/client/state/reader/accounts.ts index 18cf62c..5529b01 100644 --- a/src/client/state/reader/accounts.ts +++ b/src/client/state/reader/accounts.ts @@ -12,6 +12,8 @@ export function createAccount( return { id, game, + passTrackingMigrated: true, + legacyPassVisibility: false, region, intent: { events: true, diff --git a/src/client/state/reader/contentRouting.ts b/src/client/state/reader/contentRouting.ts new file mode 100644 index 0000000..4290cf7 --- /dev/null +++ b/src/client/state/reader/contentRouting.ts @@ -0,0 +1,189 @@ +import { classifiedEvent, displayGameFor, passPlacementId } from "../../../shared/classification.ts"; +import type { DisplayEvent } from "../../../shared/custom.ts"; +import { DisplayEventSchema, emptyAccountData, pairKey, type ReaderState } from "./schema.ts"; +import { recordOf } from "./actions.ts"; +import type { ReaderAction } from "./reducer.ts"; + +/** Identity hints from the audit; used only for old marks with no snapshot. */ +export function legacyMiliastraId(id: string): boolean { + return /^genshin:(?:starlit-gala(?:-wonderland-selects)?|silvery-moonlight-chronicle|phantasmagoric-chronicle|starlight-voyage-[a-z-]+|raiment-collection-[a-z-]+):\d{4}-\d{2}-\d{2}$/.test(id); +} +export function contentEvents(state: ReaderState): DisplayEvent[] { + const out = new Map(); + for (const raw of [ + ...Object.values(state.archive).map(r => r.event), + ...Object.values(state.seenEvents).map(r => r.event), + ...Object.values(recordOf(state.contentEvents)), + ]) { + const p = DisplayEventSchema.safeParse(raw); + if (p.success) out.set(p.data.id, p.data); + } + return [...out.values()]; +} +function routedId(state: ReaderState, account: string, item: string): string { + const to = recordOf(state.contentRoutes)[account]; + if (typeof to !== "string" || !state.accounts[to]) return account; + return legacyMiliastraId(item) || contentEvents(state).some(e => e.id === item && displayGameFor(e) === "miliastra") ? to : account; +} + +/** Late operations from a pre-upgrade tab must use destination tombstones. */ +export function routeContentAction(state: ReaderState, action: ReaderAction): ReaderAction { + if (!("accountId" in action)) return action; + const item = "itemId" in action ? action.itemId : "eventId" in action ? action.eventId : null; + if (!item) return action; + const accountId = routedId(state, action.accountId, item); + if (accountId === action.accountId) return action; + if (action.type === "archive" && action.record) + return { ...action, accountId, record: { ...action.record, accountId, event: classifiedEvent(action.record.event) } }; + return { ...action, accountId }; +} + +const families: Record = { + genshin: ["battle-pass"], miliastra: ["miliastra-pass"], hsr: ["nameless-honor"], zzz: ["city-fund"], nikki: ["mira-journey"], +}; +export const passFamiliesForGame = (game: string): string[] => (families[game] ?? []).map(f => `feed-cycle:${game}:${f}`); + +/** + * Normalize under the reader lock, before validation/save. The wire event keeps + * its source game; only saved snapshots and client rows use the logical lane. + * Every identity and day key survives. Origin timestamps remain as tombstones + * so an old tab or an old backup cannot recreate a cleared destination value. + */ +export function routeReaderContent(state: ReaderState): ReaderState { + const next = structuredClone(state); + const routes = { ...recordOf(next.contentRoutes) }; + const events = contentEvents(next); + const miliastraIds = new Set(events.filter(e => displayGameFor(e) === "miliastra").map(e => e.id)); + const isMoved = (id: string) => miliastraIds.has(id) || legacyMiliastraId(id); + const times = { ...recordOf(next.fieldTimes) } as Record; + const stamp = (v: unknown) => String(recordOf(v).at ?? recordOf(v).updatedAt ?? ""); + const fieldTime = (a: string, kind: string, id: string, field?: string) => times[JSON.stringify(field === undefined ? [a, kind, id] : [a, kind, id, field])] ?? ""; + for (const origin of Object.values(next.accounts).filter(a => a.game === "genshin")) { + const src = next.data[origin.id]; + if (!src) continue; + const ids = new Set([ + ...Object.keys(src.progress), ...Object.keys(src.ignored), + ...Object.values(src.daily).flatMap(Object.keys), + ...Object.values(src.amounts).flatMap(Object.keys), + ...(next.eventSeen[origin.id] ?? []), + ...Object.values(next.seenEvents).filter(r => r.accountId === origin.id).map(r => r.event.id), + ...Object.values(next.archive).filter(r => r.accountId === origin.id).map(r => r.event.id), + ].filter(isMoved)); + // Inventory supplies a destination even on a device with no prior marks. + if (miliastraIds.size && origin.intent.events) for (const id of miliastraIds) ids.add(id); + if (!ids.size && typeof routes[origin.id] !== "string") continue; + const placeholder = Object.values(next.accounts).find(a => a.game === "miliastra" && a.contentPlaceholder === true && a.region === origin.region && !a.contentOrigin && Object.values(next.data[a.id]?.progress ?? {}).length === 0); + const targetId = typeof routes[origin.id] === "string" ? routes[origin.id] as string : placeholder?.id ?? `account:miliastra-${origin.id.slice("account:".length)}`; + const existing = next.accounts[targetId]; + // A deterministic target may never overwrite an unrelated imported account. + if (existing && existing !== placeholder && existing.contentOrigin !== origin.id) throw new Error("content account identity collision"); + routes[origin.id] = targetId; + if (!existing || existing === placeholder) { + next.accounts[targetId] = { ...origin, id: targetId, game: "miliastra", contentOrigin: origin.id, contentPlaceholder: false, passTrackingMigrated: false, legacyPassVisibility: origin.legacyPassVisibility ?? origin.intent.events, routines: {}, intent: { events: origin.intent.events, dailies: false, weeklyMonthly: false, cycles: origin.intent.cycles } }; + next.data[targetId] = emptyAccountData(); + } + const target = next.accounts[targetId]!; + // Keep newly encountered historical revisions; never replace a destination's + // active clock or settings after its independent creation. + for (const c of origin.clocks) if (!target.clocks.some(t => t.id === c.id)) target.clocks.unshift({ ...c, historicalOnly: true }); + const dst = next.data[targetId]!; + for (const id of ids) { + if (Object.hasOwn(src.progress, id)) { + const a = recordOf(src.progress[id]), b = recordOf(dst.progress[id]), merged = { ...b }; + for (const [field, value] of Object.entries(a)) { + const sourceTime = fieldTime(origin.id, "progress", id, field) || stamp(a); + const destTime = fieldTime(targetId, "progress", id, field) || stamp(b); + if (sourceTime > destTime || (!Object.hasOwn(b, field) && !destTime)) merged[field] = value; + } + dst.progress[id] = merged; + delete src.progress[id]; + } + if (Object.hasOwn(src.ignored, id)) { + const st = fieldTime(origin.id, "ignore", id) || stamp(src.ignored[id]); + const dt = fieldTime(targetId, "ignore", id) || stamp(dst.ignored[id]); + if (st > dt || !dt) dst.ignored[id] = src.ignored[id]; + delete src.ignored[id]; + } + for (const kind of ["daily", "amounts"] as const) { + for (const [revision, items] of Object.entries(src[kind])) { + if (!Object.hasOwn(items, id)) continue; + const values = dst[kind][revision] ??= {}; + const a = recordOf(items[id]), b = recordOf(values[id]); + if (kind === "daily") { + const ad = Array.isArray(a.days) ? a.days as string[] : []; + const bd = Array.isArray(b.days) ? b.days as string[] : []; + const days = new Set(bd); + for (const day of ad) { + const path = (account: string) => JSON.stringify([account, revision, id, day]); + const st = times[path(origin.id)] || stamp(a), dt = times[path(targetId)] || stamp(b); + if (st > dt || !dt) days.add(day); + } + values[id] = { ...a, ...b, days: [...days].sort(), at: stamp(a) > stamp(b) ? stamp(a) : stamp(b) }; + } else { + const merged = { ...b }; + for (const [period, value] of Object.entries(a)) { + const path = (account: string) => JSON.stringify([account, revision, id, period]); + const st = times[path(origin.id)] || stamp(value), dt = times[path(targetId)] || stamp(b[period]); + if (st > dt || !dt) merged[period] = value; + } + values[id] = merged; + } + delete items[id]; + } + } + for (const kind of ["archive", "seenEvents"] as const) { + const from = pairKey(origin.id, id), to = pairKey(targetId, id), r = next[kind][from]; + if (!r) continue; + const old = next[kind][to]; + const rtime = String("outcomeAt" in r ? r.outcomeAt : r.lastSeenAt); + const oldTime = old ? String("outcomeAt" in old ? old.outcomeAt : old.lastSeenAt) : ""; + const deletion = kind === "archive" ? times[JSON.stringify(["archive", targetId, id])] ?? "" : ""; + if (rtime > oldTime && rtime >= deletion) { + const moved = { ...r, accountId: targetId, event: classifiedEvent(r.event) }; + if (kind === "archive") next.archive[to] = moved as ReaderState["archive"][string]; + else next.seenEvents[to] = moved as ReaderState["seenEvents"][string]; + } + delete next[kind][from]; + } + } + const movedSeen = (next.eventSeen[origin.id] ?? []).filter(isMoved); + if (movedSeen.length) { + next.eventSeen[targetId] = [...new Set([...(next.eventSeen[targetId] ?? []), ...movedSeen])]; + next.eventSeen[origin.id] = (next.eventSeen[origin.id] ?? []).filter(id => !isMoved(id)); + } + for (const [key, value] of Object.entries(times)) { + let path: unknown; + try { path = JSON.parse(key); } catch { continue; } + if (!Array.isArray(path)) continue; + if (path[0] === origin.id && path.some(v => typeof v === "string" && isMoved(v))) path[0] = targetId; + else if (path[0] === "archive" && path[1] === origin.id && typeof path[2] === "string" && isMoved(path[2])) path[1] = targetId; + else continue; + const to = JSON.stringify(path); + if (!times[to] || times[to]! < value) times[to] = value; + } + } + if (Object.keys(routes).length) { + next.contentRoutes = routes; + const known = Array.isArray(next.prefs.knownGames) ? next.prefs.knownGames as string[] : Object.values(state.accounts).map(a => a.game); + if (!known.includes("miliastra")) { + next.prefs.knownGames = [...known, "miliastra"]; + next.prefs.hiddenGames = [...new Set([...(Array.isArray(next.prefs.hiddenGames) ? next.prefs.hiddenGames : []), "miliastra"])]; + } + } + for (const account of Object.values(next.accounts)) { + if (account.passTrackingMigrated === true) continue; + const legacy = account.legacyPassVisibility === true || (account.legacyPassVisibility === undefined && account.intent.events); + for (const key of passFamiliesForGame(account.game)) account.routines[key] ??= { followed: legacy, checklist: legacy, timeline: legacy }; + account.legacyPassVisibility = legacy; + account.passTrackingMigrated = true; + } + // Preserve source identities here. This cache also exposes passes when Events + // is off, so they need not masquerade as seen/unfinished ordinary events. + for (const event of events) { + const key = passPlacementId(event); + if (!key) continue; + for (const a of Object.values(next.accounts).filter(a => a.game === displayGameFor(event))) a.routines[key] ??= { followed: a.intent.cycles, checklist: a.intent.cycles, timeline: a.intent.cycles }; + } + if (Object.keys(times).length) next.fieldTimes = times; + return JSON.stringify(next) === JSON.stringify(state) ? state : next; +} diff --git a/src/client/state/reader/eventRows.ts b/src/client/state/reader/eventRows.ts index 18c9488..dcb3606 100644 --- a/src/client/state/reader/eventRows.ts +++ b/src/client/state/reader/eventRows.ts @@ -13,6 +13,7 @@ import { import type { RowEvent } from "../../components/EventRow.tsx"; import type { Region } from "../../../shared/schema.ts"; import type { ReaderState } from "./schema.ts"; +import { classifiedEvent, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts"; export type ReaderEventRow = RowEvent & { accountId: string; region: Region; @@ -22,6 +23,7 @@ export function readerEventRows( state: ReaderState, events: readonly DisplayEvent[], now: number, + includeCycles = false, ): ReaderEventRow[] { const source = new Map( events.filter((e) => e.status === "published").map((e) => [e.id, e]), @@ -39,7 +41,6 @@ export function readerEventRows( for (const a of Object.values(state.accounts)) { if ( a.retiredAt || - !a.intent.events || (Array.isArray(state.prefs.hiddenGames) && state.prefs.hiddenGames.includes(a.game)) ) @@ -53,8 +54,12 @@ export function readerEventRows( state.customEvents[ruleIdOf(seen.event.id)]) ) scoped.set(seen.event.id, seen.event); - for (const event of scoped.values()) { + for (const raw of scoped.values()) { + const event = classifiedEvent(raw); if (event.game !== a.game) continue; + if (!tracksEventCategory(event, state.prefs)) continue; + const family = passPlacementId(event); + if (family ? !includeCycles || !a.routines[family]?.followed : !a.intent.events) continue; const clock = readerClockFor(event, a, now); out.push({ event, diff --git a/src/client/state/reader/import.ts b/src/client/state/reader/import.ts index affeb23..2470d37 100644 --- a/src/client/state/reader/import.ts +++ b/src/client/state/reader/import.ts @@ -1,6 +1,8 @@ import type { ReaderState } from "./schema.ts"; import { pairKey } from "./schema.ts"; import { recordOf } from "./actions.ts"; +import { contentEvents, legacyMiliastraId, routeReaderContent } from "./contentRouting.ts"; +import { displayGameFor } from "../../../shared/classification.ts"; const stamp = (v: unknown) => { const r = recordOf(v); return typeof r.updatedAt === "string" @@ -117,15 +119,32 @@ export function mergeReaderData( next.importedExtensions.map((value) => [JSON.stringify(value), value]), ).values(), ]; - for (const account of Object.values(incoming.accounts)) { - const mine = Object.values(next.accounts).find( + const accountMap = new Map(); + const usedTargets = new Set(); + const movedIds = new Set(contentEvents(incoming).filter(e => displayGameFor(e) === "miliastra").map(e => e.id)); + const localTimes = recordOf(current.fieldTimes), remoteTimes = recordOf(incoming.fieldTimes); + const mergedTimes = { ...localTimes }; + for (const account of Object.values(incoming.accounts).sort((a, b) => Number(a.game === "miliastra") - Number(b.game === "miliastra"))) { + const origin = typeof account.contentOrigin === "string" ? accountMap.get(account.contentOrigin) ?? account.contentOrigin : undefined; + const hasMovedData = account.game === "genshin" && (typeof recordOf(incoming.contentRoutes)[account.id] === "string" || [ + ...Object.keys(incoming.data[account.id]?.progress ?? {}), + ...Object.keys(incoming.data[account.id]?.ignored ?? {}), + ...Object.values(incoming.data[account.id]?.daily ?? {}).flatMap(Object.keys), + ...Object.values(incoming.data[account.id]?.amounts ?? {}).flatMap(Object.keys), + ...Object.values(incoming.seenEvents).filter(r => r.accountId === account.id).map(r => r.event.id), + ...Object.values(incoming.archive).filter(r => r.accountId === account.id).map(r => r.event.id), + ].some(id => legacyMiliastraId(id) || movedIds.has(id))); + const exact = next.accounts[account.id]; + const mine = (exact?.game === account.game ? exact : undefined) ?? Object.values(current.accounts).find( (a) => - a.game === account.game && a.region === account.region && !a.retiredAt, + !hasMovedData && a.game === account.game && a.region === account.region && !a.retiredAt && !usedTargets.has(a.id) && (account.game !== "miliastra" || a.contentOrigin === origin), ); let id = mine?.id ?? account.id; let suffix = 0; while (!mine && next.accounts[id]) id = `account:import-${++suffix}`; - next.accounts[id] = mine ?? { ...account, id }; + next.accounts[id] = mine ?? { ...account, id, ...(origin ? { contentOrigin: origin } : {}) }; + accountMap.set(account.id, id); + usedTargets.add(id); next.eventSeen[id] = [ ...new Set([ ...(next.eventSeen[id] ?? []), @@ -176,7 +195,16 @@ export function mergeReaderData( ...(Array.isArray(b.days) ? b.days : []), ]), ] - .filter((d): d is string => typeof d === "string") + .filter((d): d is string => { + if (typeof d !== "string") return false; + const local = recordOf(current.fieldTimes)[JSON.stringify([id, target, item, d])]; + const imported = recordOf(incoming.fieldTimes)[JSON.stringify([account.id, revision, item, d])]; + // A local deletion is authoritative over an older backup. Explicit + // newer imported operations can still restore that day. + const remoteTime = String(imported ?? ""), localTime = String(local ?? ""); + if (remoteTime > localTime && remoteTime >= stamp(a)) return Array.isArray(b.days) && b.days.includes(d); + return !localTime || remoteTime > localTime || (Array.isArray(a.days) && a.days.includes(d)); + }) .sort(); merged[item] = { ...a, @@ -190,8 +218,19 @@ export function mergeReaderData( for (const [revision, items] of Object.entries(data.amounts)) { const target = revisionMap.get(revision) ?? revision; const merged = { ...amounts[target] }; - for (const [item, entry] of Object.entries(items)) - merged[item] = mergeRecords(recordOf(merged[item]), recordOf(entry)); + for (const [item, entry] of Object.entries(items)) { + const a = recordOf(merged[item]), b = recordOf(entry), values = { ...a }; + const periods = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of Object.keys(remoteTimes)) { + try { const p = JSON.parse(key); if (p[0] === account.id && p[1] === revision && p[2] === item && typeof p[3] === "string") periods.add(p[3]); } catch { /* opaque extension */ } + } + for (const period of periods) { + const lt = String(localTimes[JSON.stringify([id, target, item, period])] ?? stamp(a[period])); + const rt = String(remoteTimes[JSON.stringify([account.id, revision, item, period])] ?? stamp(b[period])); + if (rt > lt) { if (b[period] === undefined) delete values[period]; else values[period] = b[period]; } + } + merged[item] = values; + } amounts[target] = merged; } next.data[id] = { @@ -202,11 +241,35 @@ export function mergeReaderData( daily, amounts, }; + const resultData = next.data[id]!; + for (const item of new Set([...Object.keys(old?.progress ?? {}), ...Object.keys(data.progress)])) { + const a = recordOf(old?.progress[item]), b = recordOf(data.progress[item]); + const value = { ...recordOf(resultData.progress[item]) }; + const fields = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of Object.keys(remoteTimes)) { + try { const p = JSON.parse(key); if (p[0] === account.id && p[1] === "progress" && p[2] === item && typeof p[3] === "string") fields.add(p[3]); } catch { /* opaque extension */ } + } + for (const field of fields) { + const lt = String(localTimes[JSON.stringify([id, "progress", item, field])] ?? ""); + const rt = String(remoteTimes[JSON.stringify([account.id, "progress", item, field])] ?? ""); + if (lt && lt >= (rt || stamp(b))) { if (a[field] === undefined) delete value[field]; else value[field] = a[field]; } + else if (rt && rt >= stamp(a)) { if (b[field] === undefined) delete value[field]; else value[field] = b[field]; } + } + const restoreTime = String(localTimes[JSON.stringify(["archive", id, item])] ?? ""); + if (restoreTime > stamp(b) && !current.archive[pairKey(id, item)] && a.status !== "done") delete value.status; + resultData.progress[item] = value; + } + for (const item of new Set([...Object.keys(old?.ignored ?? {}), ...Object.keys(data.ignored)])) { + const lt = String(localTimes[JSON.stringify([id, "ignore", item])] ?? ""); + const rt = String(remoteTimes[JSON.stringify([account.id, "ignore", item])] ?? stamp(data.ignored[item])); + if (lt && lt >= rt) { if (old?.ignored[item] === undefined) delete resultData.ignored[item]; else resultData.ignored[item] = old.ignored[item]; } + else if (rt && data.ignored[item] === undefined) delete resultData.ignored[item]; + } for (const r of Object.values(incoming.archive).filter( (r) => r.accountId === account.id, )) { const key = pairKey(id, r.event.id); - if (!next.archive[key] || next.archive[key]!.outcomeAt < r.outcomeAt) + if ((!next.archive[key] || next.archive[key]!.outcomeAt < r.outcomeAt) && String(localTimes[JSON.stringify(["archive", id, r.event.id])] ?? "") <= r.outcomeAt) next.archive[key] = { ...r, accountId: id, @@ -231,6 +294,26 @@ export function mergeReaderData( ...current.codeRedeemed[id], ...incoming.codeRedeemed[account.id], }; + for (const [key, time] of Object.entries(remoteTimes)) { + try { + const path = JSON.parse(key); + if (!Array.isArray(path)) continue; + if (path[0] === account.id) { + path[0] = id; + if (typeof path[1] === "string" && revisionMap.has(path[1])) path[1] = revisionMap.get(path[1]); + } else if (path[0] === "archive" && path[1] === account.id) path[1] = id; + else continue; + const target = JSON.stringify(path); + if (String(mergedTimes[target] ?? "") < String(time)) mergedTimes[target] = time; + } catch { /* Opaque field paths remain in imported extensions. */ } + } } - return next; + const routes = { ...recordOf((next as ReaderState).contentRoutes) }; + for (const [from, to] of Object.entries(recordOf(incoming.contentRoutes))) { + const origin = accountMap.get(from), target = typeof to === "string" ? accountMap.get(to) : undefined; + if (origin && target && routes[origin] === undefined) routes[origin] = target; + } + if (Object.keys(routes).length) (next as ReaderState).contentRoutes = routes; + if (Object.keys(mergedTimes).length) (next as ReaderState).fieldTimes = mergedTimes; + return routeReaderContent(next); } diff --git a/src/client/state/reader/migration.ts b/src/client/state/reader/migration.ts index 9298655..6cb33d6 100644 --- a/src/client/state/reader/migration.ts +++ b/src/client/state/reader/migration.ts @@ -111,7 +111,7 @@ export function migrateLegacy( throw new Error("Duplicate minted account identity"); const revision = "initial"; const routineRuleValue = routineRule(prefs); - const chore = shownFor(choreRule(prefs), game); + const chore = game !== "miliastra" && shownFor(choreRule(prefs), game); const routines = Object.fromEntries( routinesFor(game).map((r) => { const followed = @@ -136,7 +136,7 @@ export function migrateLegacy( timeline: shown, }; } - if (GameId.safeParse(game).success) + if (game !== "miliastra" && GameId.safeParse(game).success) routines[`dailies:${game}`] = { followed: chore, checklist: chore, @@ -145,6 +145,7 @@ export function migrateLegacy( state.accounts[id] = { id, game, + ...(game === "miliastra" ? { contentPlaceholder: true } : {}), region: prefs.region, intent: { events: !prefs.hiddenGames.includes(game), diff --git a/src/client/state/reader/onboarding.ts b/src/client/state/reader/onboarding.ts index 297a804..7ca1c95 100644 --- a/src/client/state/reader/onboarding.ts +++ b/src/client/state/reader/onboarding.ts @@ -4,6 +4,7 @@ import { routinesFor, offered } from "../../../shared/routines.ts"; import { createAccount, changeAccountRegion } from "./accounts.ts"; import type { AccountId, ReaderState, TrackingIntent } from "./schema.ts"; import { cadenceOf } from "../../../shared/period.ts"; +import { passFamiliesForGame } from "./contentRouting.ts"; export interface SetupDraft { region: Region; interface: "relaxed" | "structured"; @@ -51,6 +52,7 @@ export function setupReader( routines[r.id] = { followed, checklist: followed, timeline: followed }; } accounts[a.id] = { ...a, intent, routines }; + for (const id of passFamiliesForGame(game)) routines[id] = { followed: intent.cycles, checklist: intent.cycles, timeline: intent.cycles }; } for (const [id, a] of Object.entries(accounts)) if (!draft.games.includes(a.game)) diff --git a/src/client/state/reader/reducer.ts b/src/client/state/reader/reducer.ts index 156084c..ca4f482 100644 --- a/src/client/state/reader/reducer.ts +++ b/src/client/state/reader/reducer.ts @@ -1,4 +1,8 @@ import { applyReaderAliases } from "./aliases.ts"; +import { contentEvents, routeContentAction, routeReaderContent } from "./contentRouting.ts"; +import type { DisplayEvent } from "../../../shared/custom.ts"; +import { classificationFor, classifiedEvent } from "../../../shared/classification.ts"; +import { resolveReaderClock } from "../../../shared/readerClock.ts"; import { archiveOutcome, restoreArchive, reconcileArchive } from "./archive.ts"; import { type Account, @@ -9,6 +13,7 @@ import { pairKey, } from "./schema.ts"; export type ReaderAction = { at: string; opId: string } & ( + | { type: "content"; events: DisplayEvent[] } | { type: "outcome"; accountId: string; @@ -103,6 +108,8 @@ export function reduceReader( state: ReaderState, action: ReaderAction, ): ReaderState { + state = routeReaderContent(state); + action = routeContentAction(state, action); const operations = Array.isArray(state.operations) ? (state.operations as string[]) : []; @@ -120,8 +127,14 @@ export function reduceReader( ...Object.fromEntries(fields.map((k) => [k, action.at])), }, }; + if (action.type === "content") return routeReaderContent({ ...next, contentEvents: Object.fromEntries(action.events.filter(e => Object.keys(classificationFor(e)).length > 0).map(e => [e.id, e])) }); if (action.type === "aliases") return applyReaderAliases(next, action.table); - if (action.type === "outcome") + if (action.type === "outcome") { + const key = pairKey(action.accountId, action.eventId); + const a = next.accounts[action.accountId]; + const event = contentEvents(next).find(e => e.id === action.eventId); + if (!next.seenEvents[key] && a && event) + next.seenEvents = { ...next.seenEvents, [key]: { accountId: a.id, event: classifiedEvent(event), firstSeenAt: action.at, lastSeenAt: action.at, clockRevision: resolveReaderClock(a, Date.parse(action.at)).revision } }; return archiveOutcome( next, action.accountId, @@ -129,6 +142,7 @@ export function reduceReader( action.outcome, action.at, ); + } if (action.type === "restore") return restoreArchive(next, action.accountId, action.eventId); if (action.type === "reconcile") diff --git a/src/client/state/reader/routines.ts b/src/client/state/reader/routines.ts index c31edfd..b983314 100644 --- a/src/client/state/reader/routines.ts +++ b/src/client/state/reader/routines.ts @@ -16,6 +16,9 @@ import { import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts"; import type { GoalStatus } from "../../../shared/goal.ts"; import type { Account, ReaderState, RoutinePlacement } from "./schema.ts"; +import { classifiedEvent, displayGameFor, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts"; +import { contentEvents } from "./contentRouting.ts"; +import type { DisplayEvent } from "../../../shared/custom.ts"; export interface RoutineRow { id: string; accountId: string; @@ -28,6 +31,10 @@ export interface RoutineRow { days: string[]; source: string | null; task: Task | null; + /** A source-dated pass; progress is still keyed by this window's event ID. */ + event?: DisplayEvent; + placementId?: string; + window?: { start: number; end: number }; } export function placementFor(account: Account, id: string): RoutinePlacement { return ( @@ -57,7 +64,7 @@ export function routineRows( : []; }; const rows: RoutineRow[] = []; - if (!isCustomGameId(account.game)) { + if (!isCustomGameId(account.game) && account.game !== "miliastra") { const id = `dailies:${account.game}`, days = daysFor(id), count = days.includes(today) ? 1 : 0; @@ -146,6 +153,8 @@ export function routineRows( goal = readGoal(p.goal), window = readerClockFor(event, account, now); if ( + !tracksEventCategory(event, state.prefs) || + passPlacementId(event) !== null || !window.live || state.archive[JSON.stringify([accountId, event.id])] || p.status === "done" || @@ -207,5 +216,21 @@ export function routineRows( task: null, }); } + for (const raw of contentEvents(state)) { + if (displayGameFor(raw) !== account.game) continue; + const event = classifiedEvent(raw), placementId = passPlacementId(event); + if (!placementId) continue; + const window = readerClockFor(event, account, now); + if (window.ended || window.endsMs === null || (window.upcoming && !state.prefs.showUpcoming)) continue; + const p = recordOf(data.progress[event.id]); + const archived = state.archive[JSON.stringify([accountId, event.id])]; + const met = p.status === "done" || data.ignored[event.id] !== undefined || archived?.outcome === "done" || archived?.outcome === "ignored"; + rows.push({ + id: event.id, accountId, game: account.game, title: event.title, + cadence: "cycle", status: { scope: "event", measure: "days", periodStart: event.startsAt.slice(0, 10), required: 1, count: met ? 1 : 0, met, needed: met ? 0 : 1, open: met ? 0 : 1, shortfall: false }, + placement: placementFor(account, placementId), placementId, event, + window: { start: window.startsMs, end: window.endsMs }, clock, days: [], source: event.sourceUrl, task: null, + }); + } return rows; } diff --git a/src/client/state/reader/store.ts b/src/client/state/reader/store.ts index 227b80a..eddebc7 100644 --- a/src/client/state/reader/store.ts +++ b/src/client/state/reader/store.ts @@ -11,6 +11,7 @@ import { type SaveResult, } from "./schema.ts"; import { reduceReader, type ReaderAction } from "./reducer.ts"; +import { routeReaderContent } from "./contentRouting.ts"; export interface ReaderStore { getSnapshot(): ReaderState; subscribe(listener: () => void): () => void; @@ -58,10 +59,10 @@ export function initializeReader( if (saved !== null) { const parsed = ReaderStateSchema.safeParse(JSON.parse(saved)); if (!parsed.success) return { ok: false, reason: "invalid" }; - const next = refreshAutomaticClocks( + const next = routeReaderContent(refreshAutomaticClocks( reconcileLegacy(parsed.data, readLegacy(storage), at), at, - ); + )); if (next !== parsed.data) { const result = tryWriteJson(storage, READER_KEYS.reader, next); if (!result.ok) return result; @@ -102,7 +103,7 @@ export function createReaderStore( const apply = (action: ReaderAction): SaveResult => { try { const current = persisted() ?? state; - const next = reduceReader(current, action); + const next = routeReaderContent(reduceReader(current, action)); if (next === current) { state = current; notify(); diff --git a/src/client/state/reader/updates.ts b/src/client/state/reader/updates.ts index 2e4f879..9a48406 100644 --- a/src/client/state/reader/updates.ts +++ b/src/client/state/reader/updates.ts @@ -1,5 +1,6 @@ import { freshness, type EventFeed } from "../../../shared/feed.ts"; import type { ReaderState } from "./schema.ts"; +import { displayGameFor, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts"; export type UpdateItem = | { kind: "sources"; games: string[] } | { kind: "events"; accountId: string; ids: string[] } @@ -27,9 +28,10 @@ export function updateItems( ): UpdateItem[] { const items: UpdateItem[] = [], tracked = trackedGames(state); - const stale = freshness(feed.sources, now).stale.filter((s) => - tracked.has(s.game), - ); + const stale = freshness(feed.sources, now).stale.flatMap(s => [ + ...(tracked.has(s.game) ? [s] : []), + ...(s.game === "genshin" && tracked.has("miliastra") ? [{ ...s, game: "miliastra" as const }] : []), + ]); if (stale.length && !snoozed(state.prefs.staleSnooze, stale, now)) items.push({ kind: "sources", games: stale.map((s) => s.game) }); for (const a of Object.values(state.accounts)) { @@ -42,7 +44,7 @@ export function updateItems( continue; const ids = feed.events .filter( - (e) => e.game === a.game && !state.eventSeen[a.id]!.includes(e.id), + (e) => displayGameFor(e) === a.game && !passPlacementId(e) && tracksEventCategory(e, state.prefs) && !state.eventSeen[a.id]!.includes(e.id), ) .map((e) => e.id); if (ids.length) items.push({ kind: "events", accountId: a.id, ids }); diff --git a/test/game-settings.test.tsx b/test/game-settings.test.tsx index 9ff18ff..bea1c5e 100644 --- a/test/game-settings.test.tsx +++ b/test/game-settings.test.tsx @@ -31,8 +31,9 @@ test("game settings explains simultaneous server tracking and credits zero-event expect(html).toContain("Reset to default"); // Tracking choices save on click; there is no separate apply step. expect(html).not.toContain("Apply tracking choices"); - // A kind with no built-in routine is explained and still selectable. - expect(html).toContain("none built in"); + // Pass families are offered with cycles, even without a new source window. + expect(html).toContain("Cycles"); + expect(html).toContain("1 built in"); expect(html).not.toMatch(/]*disabled[^>]*data-st/); }); diff --git a/test/reader-content-routing.test.ts b/test/reader-content-routing.test.ts new file mode 100644 index 0000000..fe254fa --- /dev/null +++ b/test/reader-content-routing.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test"; +import { migrateLegacy } from "../src/client/state/reader/migration.ts"; +import { contentEvents, routeReaderContent } from "../src/client/state/reader/contentRouting.ts"; +import { ReaderStateSchema, pairKey } from "../src/client/state/reader/schema.ts"; +import { reduceReader } from "../src/client/state/reader/reducer.ts"; +import { createAccount } from "../src/client/state/reader/accounts.ts"; +import { sampleEvent } from "./helpers/reader.ts"; +import { mergeReaderData } from "../src/client/state/reader/import.ts"; +const at = "2026-11-01T12:00:00.000Z"; +const event = { ...sampleEvent, id: "genshin:starlit-gala:2026-10-01", title: "Starlit Gala", endsAt: "2026-11-03T00:00:00.000Z", sourceUrl: "https://genshin-impact.fandom.com/wiki/Starlit_Gala" }; +function saved() { + const s = migrateLegacy({}, ["genshin"], at, () => "account:one"); + s.prefs.onboarded = true; + s.seenEvents[pairKey("account:one", event.id)] = { accountId: "account:one", event, firstSeenAt: at, lastSeenAt: at, clockRevision: "initial" }; + s.data["account:one"]!.progress[event.id] = { note: "keep me", status: "doing", at }; + s.data["account:one"]!.daily.initial = { [event.id]: { days: ["2026-10-31"], at } }; + return s; +} +test("routes only Miliastra state with the same IDs and clock references, hidden initially", () => { + const before = saved(); + const after = routeReaderContent(before); + const target = Object.values(after.accounts).find(a => a.game === "miliastra")!; + expect(target).toBeDefined(); + expect(after.data[target.id]?.progress[event.id]).toEqual(before.data["account:one"]!.progress[event.id]); + expect(after.data["account:one"]!.progress[event.id]).toBeUndefined(); + expect(after.data[target.id]?.daily.initial?.[event.id]).toEqual({ days: ["2026-10-31"], at }); + expect(after.seenEvents[pairKey(target.id, event.id)]?.event.game).toBe("miliastra"); + expect(after.prefs.hiddenGames).toContain("miliastra"); + expect(ReaderStateSchema.safeParse(after).success).toBe(true); + expect(routeReaderContent(after)).toEqual(after); + expect(before.data["account:one"]!.progress[event.id]).toBeDefined(); +}); +test("two same-region origins retain different counterpart identities", () => { + const s = saved(); + s.accounts["account:two"] = createAccount("genshin", "asia", at, "account:two"); + s.data["account:two"] = { ...s.data["account:one"]!, progress: { [event.id]: { note: "second" } } }; + const n = routeReaderContent(s); + const accounts = Object.values(n.accounts).filter(a => a.game === "miliastra"); + expect(accounts).toHaveLength(2); + expect(accounts.map(a => n.data[a.id]!.progress[event.id])).toContainEqual({ note: "second" }); +}); +test("old-account writes route before conflict checks; a cleared field cannot be resurrected", () => { + let n = routeReaderContent(saved()); + const target = Object.values(n.accounts).find(a => a.game === "miliastra")!; + n = reduceReader(n, { type: "progress", accountId: target.id, itemId: event.id, patch: { note: null }, at: "2026-11-01T13:00:00.000Z", opId: "clear" }); + expect(() => reduceReader(n, { type: "progress", accountId: "account:one", itemId: event.id, patch: { note: "stale" }, at, opId: "late" })).toThrow("conflict"); + const next = reduceReader(n, { type: "progress", accountId: "account:one", itemId: event.id, patch: { note: "new" }, at: "2026-11-01T14:00:00.000Z", opId: "new" }); + expect(next.data[target.id]!.progress[event.id]).toHaveProperty("note", "new"); + expect(next.data["account:one"]!.progress[event.id]).toBeUndefined(); +}); +test("an old backup cannot resurrect a cleared migrated day or note", () => { + const old = saved(); + let current = routeReaderContent(old); + const target = Object.values(current.accounts).find(a => a.game === "miliastra")!; + const later = "2026-11-01T15:00:00.000Z"; + current = reduceReader(current, { type: "progress", accountId: target.id, itemId: event.id, patch: { note: null }, at: later, opId: "clear-note" }); + current = reduceReader(current, { type: "day", accountId: target.id, revisionId: "initial", itemId: event.id, day: "2026-10-31", checked: false, at: later, opId: "clear-day" }); + const result = mergeReaderData(current, old); + expect((result.data[target.id]?.progress[event.id] as { note?: string })?.note).toBeUndefined(); + expect(result.data[target.id]?.daily.initial?.[event.id]).toHaveProperty("days", []); + expect(result.data["account:one"]?.progress[event.id]).toBeUndefined(); + expect(ReaderStateSchema.safeParse(result).success).toBe(true); +}); +test("a migrated backup cannot resurrect a destination day deletion", () => { + const old = routeReaderContent(saved()); + const target = Object.values(old.accounts).find(a => a.game === "miliastra")!; + const current = reduceReader(old, { type: "day", accountId: target.id, revisionId: "initial", itemId: event.id, day: "2026-10-31", checked: false, at: "2026-11-01T15:00:00.000Z", opId: "remove" }); + const result = mergeReaderData(current, old); + expect(result.data[target.id]?.daily.initial?.[event.id]).toHaveProperty("days", []); +}); +test("a routed backup respects cleared ignores, amounts and restored history", () => { + let initial = routeReaderContent(saved()); + const target = Object.values(initial.accounts).find(a => a.game === "miliastra")!; + initial = reduceReader(initial, { type: "ignore", accountId: target.id, itemId: event.id, ignored: true, at, opId: "ignore" }); + initial = reduceReader(initial, { type: "amount", accountId: target.id, revisionId: "initial", itemId: event.id, period: "event", value: 3, at, opId: "amount" }); + initial = reduceReader(initial, { type: "outcome", outcome: "done", accountId: target.id, eventId: event.id, at, opId: "done" }); + let current = reduceReader(initial, { type: "restore", accountId: target.id, eventId: event.id, at: "2026-11-01T15:00:00.000Z", opId: "restore" }); + current = reduceReader(current, { type: "ignore", accountId: target.id, itemId: event.id, ignored: false, at: "2026-11-01T15:00:00.000Z", opId: "unignore" }); + current = reduceReader(current, { type: "amount", accountId: target.id, revisionId: "initial", itemId: event.id, period: "event", value: null, at: "2026-11-01T15:00:00.000Z", opId: "clear-amount" }); + const result = mergeReaderData(current, initial); + expect(result.data[target.id]?.ignored[event.id]).toBeUndefined(); + expect((result.data[target.id]?.amounts.initial?.[event.id] as Record)?.event).toBeUndefined(); + expect(result.archive[pairKey(target.id, event.id)]).toBeUndefined(); +}); +test("legacy object ignores respect a newer destination clear", () => { + const old = saved(); + old.data["account:one"]!.ignored[event.id] = { at }; + const first = routeReaderContent(old); + const target = Object.values(first.accounts).find(a => a.game === "miliastra")!; + const current = reduceReader(first, { type: "ignore", accountId: target.id, itemId: event.id, ignored: false, at: "2026-11-01T15:00:00.000Z", opId: "unignore" }); + expect(mergeReaderData(current, old).data[target.id]?.ignored[event.id]).toBeUndefined(); +}); +test("a later inventory retains current pass dates over remembered snapshots", () => { + const s = saved(); + const current = { ...event, endsAt: "2026-11-05T00:00:00.000Z" }; + s.contentEvents = { [event.id]: current }; + expect(contentEvents(s).find(e => e.id === event.id)?.endsAt).toBe(current.endsAt); +}); +test("an unknown imported origin cannot overwrite a same-region counterpart", () => { + const current = routeReaderContent(saved()); + const other = migrateLegacy({}, ["genshin"], at, () => "account:different"); + other.data["account:different"]!.progress[event.id] = { note: "different", at: "2026-11-01T16:00:00.000Z" }; + const result = mergeReaderData(current, other); + expect(result.accounts["account:different"]).toBeDefined(); + const original = Object.values(result.accounts).find(a => a.contentOrigin === "account:one")!; + expect(result.data[original.id]?.progress[event.id]).toHaveProperty("note", "keep me"); +}); +test("an already-routed backup retains its distinct origin mapping", () => { + const current = routeReaderContent(saved()); + const other = migrateLegacy({}, ["genshin"], at, () => "account:different"); + other.data["account:different"]!.progress[event.id] = { note: "different", at }; + const backup = routeReaderContent(other); + const result = mergeReaderData(current, backup); + expect(result.accounts["account:different"]).toBeDefined(); + expect(Object.values(result.accounts).filter(a => a.game === "miliastra")).toHaveLength(2); +}); +test("new-reader lane offering does not produce duplicate Miliastra accounts", () => { + let i = 0; + const s = migrateLegacy({}, ["genshin", "miliastra"], at, () => `account:new${++i}`); + s.contentEvents = { [event.id]: event }; + const result = routeReaderContent(s); + expect(Object.values(result.accounts).filter(a => a.game === "miliastra")).toHaveLength(1); +}); +test("a counterpart created after startup inherits legacy pass visibility", () => { + let s = migrateLegacy({}, ["genshin"], at, () => "account:one"); + s = routeReaderContent(s); + const pass = { ...event, id: "genshin:silvery-moonlight-chronicle:2026-09-23", sourceUrl: "https://genshin-impact.fandom.com/wiki/Miliastra_Pass/2026-09-23" }; + s.contentEvents = { [pass.id]: pass }; + const result = routeReaderContent(s); + const target = Object.values(result.accounts).find(a => a.game === "miliastra")!; + expect(target.routines["feed-cycle:miliastra:miliastra-pass"]?.followed).toBe(true); +}); diff --git a/test/reader-pass-cycles.test.ts b/test/reader-pass-cycles.test.ts new file mode 100644 index 0000000..76f19d0 --- /dev/null +++ b/test/reader-pass-cycles.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { migrateLegacy } from "../src/client/state/reader/migration.ts"; +import { routineRows } from "../src/client/state/reader/routines.ts"; +import { readerEventRows } from "../src/client/state/reader/eventRows.ts"; +import { routeReaderContent } from "../src/client/state/reader/contentRouting.ts"; +import { routinePeriod } from "../src/client/components/redesign/TimelinePage.tsx"; +import { sampleEvent } from "./helpers/reader.ts"; +const at = "2026-11-01T12:00:00.000Z", now = Date.parse(at); +const pass = { ...sampleEvent, id: "genshin:moontrace:2026-09-23", title: "Moontrace", startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-02T00:00:00.000Z", sourceUrl: "https://genshin-impact.fandom.com/wiki/Battle_Pass/Moontrace" }; +test("an existing pass becomes a sourced cycle once, with family pins and existing progress", () => { + const old = migrateLegacy({}, ["genshin"], at, () => "account:one"); + old.contentEvents = { [pass.id]: pass }; + const s = routeReaderContent(old); + const r = routineRows(s, "account:one", now).find(r => r.id === pass.id)!; + expect(r).toBeDefined(); + expect(r.cadence).toBe("cycle"); + expect(r.placementId).toBe("feed-cycle:genshin:battle-pass"); + expect(r.placement.followed).toBe(true); + expect(readerEventRows(s, [pass], now)).toHaveLength(0); + expect(routinePeriod(r, now).end).toBe(Date.parse("2026-11-02T03:00:00.000Z")); + s.accounts["account:one"]!.intent.events = false; + expect(routineRows(s, "account:one", now).find(r => r.id === pass.id)).toBeDefined(); +}); +test("a new sourced pass inherits placement, never the previous run's completion", () => { + let s = migrateLegacy({}, ["genshin"], at, () => "account:one"); + s.contentEvents = { [pass.id]: pass }; + s = routeReaderContent(s); + s.data["account:one"]!.progress[pass.id] = { status: "done", at }; + const next = { ...pass, id: "genshin:next:2026-11-04", title: "Next", startsAt: "2026-11-04T00:00:00.000Z", endsAt: "2026-12-14T00:00:00.000Z" }; + s.contentEvents = { [next.id]: next }; + const row = routineRows(s, "account:one", Date.parse("2026-11-05T12:00:00Z")).find(r => r.id === next.id)!; + expect(row.placement.followed).toBe(true); + expect(row.status.met).toBe(false); +}); diff --git a/test/reader-tcg.test.tsx b/test/reader-tcg.test.tsx new file mode 100644 index 0000000..a1ea76a --- /dev/null +++ b/test/reader-tcg.test.tsx @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GameSettings } from "../src/client/components/redesign/GameSettings.tsx"; +import { migrateLegacy } from "../src/client/state/reader/migration.ts"; +import { readerEventRows } from "../src/client/state/reader/eventRows.ts"; +import { routineRows } from "../src/client/state/reader/routines.ts"; +import { pairKey } from "../src/client/state/reader/schema.ts"; +import { sampleEvent } from "./helpers/reader.ts"; +const at = "2026-11-01T12:00:00.000Z"; +const event = { ...sampleEvent, id: "genshin:the-forge-realms-temper-clever-stratagems:2026-09-23", title: "The Forge Realm's Temper: Clever Stratagems", endsAt: "2026-11-03T00:00:00.000Z", sourceUrl: "https://genshin-impact.fandom.com/wiki/The_Forge_Realm%27s_Temper/2026-09-23" }; +test("TCG defaults on; off hides remembered events and dailies without changing history", () => { + const s = migrateLegacy({}, ["genshin"], at, () => "account:one"); + s.seenEvents[pairKey("account:one", event.id)] = { accountId: "account:one", event, firstSeenAt: at, lastSeenAt: at, clockRevision: "initial" }; + s.data["account:one"]!.progress[event.id] = { daily: true, note: "keep" }; + expect(readerEventRows(s, [], Date.parse(at))).toHaveLength(1); + s.prefs.genshinTcg = false; + expect(readerEventRows(s, [], Date.parse(at))).toHaveLength(0); + expect(routineRows(s, "account:one", Date.parse(at)).find(r => r.id === event.id)).toBeUndefined(); + expect(s.data["account:one"]!.progress[event.id]).toHaveProperty("note", "keep"); + expect(s.seenEvents[pairKey("account:one", event.id)]).toBeDefined(); +}); +test("Genshin About offers the TCG tracking preference", () => { + const s = migrateLegacy({}, ["genshin"], at, () => "account:one"); + const html = renderToStaticMarkup( ({ ok: true })} />); + expect(html).toContain("Track TCG events"); +});