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"); +});