feat(reader): separate Miliastra and track passes as cycles

Route source-classified activities into an independent Miliastra lane and
show dated battle passes once under Cycles. Genshin's About tab can disable
TCG tracking without deleting progress or history.

Activate routing and presentation together so no intermediate version moves
saved progress while still rendering its old owner. Preserve identities,
clock revisions and deletion timestamps across late writes and backups.
This commit is contained in:
Lucas Winther committed 2026-11-01 04:12:14 +01:00
1 parent 5576e5ad5c
commit b03a6ed2d1
31 files changed
+1127 -52

No files matched your search

+76
View File
@@ -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.
+12
View File
@@ -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.
+37
View File
@@ -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<typeof GachaEvent>;
@@ -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:<lane>:<family>"]`;
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.
+16
View File
@@ -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.
+25
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+13 -4
View File
@@ -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" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
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 (
@@ -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 <div className="routine-control"><strong>{row.title}</strong><RoutineProgress row={row} onAction={onAction} now={now} /></div>;
return (
<div className="routine-control">
{burst > 0 && <Fireworks key={burst} />}
@@ -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<SaveResult>;
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<Region>("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({
</div>
)}
</div>
{selected === "genshin" && (
<section className="grp">
<h3>Event tracking</h3>
<label className="trow">
<span>Track TCG events</span>
<input type="checkbox" checked={state.prefs.genshinTcg !== false} disabled={savingTcg}
onChange={async (e) => {
const value = e.target.checked;
setSavingTcg(true);
const result = await onAction({ type: "prefs", patch: { genshinTcg: value }, ...actionMeta() });
setSavingTcg(false);
setMessage(result.ok ? "" : "TCG tracking could not be saved. Your previous setting is kept.");
}} />
</label>
<p className="help">Show Genius Invokation TCG events in your lists and timeline.</p>
<p className="help">Miliastra Wonderland has its own game settings. Enable it in Your games to track its activities separately.</p>
{message && <p role="status">{message}</p>}
</section>
)}
<section className="grp">
<h3>Data sources</h3>
{sources.filter((s) => s.game === selected).length === 0 ? (
{sources.filter((s) => s.game === sourceGameFor(selected)).length === 0 ? (
<p className="muted-copy">No built-in source for this game.</p>
) : (
<>
{sources
.filter((s) => s.game === selected)
.filter((s) => s.game === sourceGameFor(selected))
.map((src) => (
<p className="game-source" key={src.sourceId}>
<a href={src.url} target="_blank" rel="noreferrer">
@@ -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<TrackingIntent>(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) => {
@@ -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}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
@@ -232,7 +232,7 @@ function Library({
const a = writes.get(row.accountId) ?? state.accounts[row.accountId]!;
writes.set(row.accountId, {
...a,
routines: { ...a.routines, [row.id]: follow(row, on) },
routines: { ...a.routines, [row.placementId ?? row.id]: follow(row, on) },
});
}
return write(writes);
@@ -302,7 +302,7 @@ function Library({
a.id,
routineRows(state, a.id, now).filter(
(r) =>
r.id.startsWith("routine:") || r.id.startsWith("dailies:"),
r.id.startsWith("routine:") || r.id.startsWith("dailies:") || r.event !== undefined,
),
]),
);
@@ -313,7 +313,7 @@ function Library({
.map((r) => r.id)
.filter(
(id) =>
id.startsWith("dailies:") || builtins.some((b) => b.id === id),
id.startsWith("dailies:") || builtins.some((b) => b.id === id) || (byAccount.get(mine[0]!.id) ?? []).some(r => r.id === id && r.event),
);
const name = meta(game).name;
const shown = ids.filter((id) => {
@@ -383,7 +383,7 @@ function Library({
def?.period.unit === "version"
? versionWindow(def.game, now)
: undefined;
const line = def
const line = first.event ? ["Battle pass", "Uses its sourced window", "Mark complete when finished"] : def
? [
def.period.unit === "version"
? version
@@ -20,6 +20,13 @@ export function RoutineProgress({
now: number;
}) {
const [editing, setEditing] = useState(false);
if (row.event) return <button type="button" className="tick" aria-pressed={row.status.met}
aria-label={`${row.title}, ${row.status.met ? "done. Undo" : "mark complete"}`}
onClick={() => void onAction(row.status.met
? { type: "restore", accountId: row.accountId, eventId: row.id, ...actionMeta() }
: { type: "outcome", outcome: "done", accountId: row.accountId, eventId: row.id, ...actionMeta() })}>
<span className="box" aria-hidden="true"><Check /></span>
</button>;
if (row.status.measure !== "amount") {
const today = readerDayKey(now, row.clock),
done = row.days.includes(today),
@@ -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({
</div>
</div>
<div className="band-reset">
<small>Next reset in</small>
<small>Next deadline in</small>
<strong>
{(() => {
// 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"}
</small>
)}
</td>
@@ -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"),
@@ -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"}`}
</span>
</button>
{!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)}
</>
)}
+2
View File
@@ -12,6 +12,8 @@ export function createAccount(
return {
id,
game,
passTrackingMigrated: true,
legacyPassVisibility: false,
region,
intent: {
events: true,
+189
View File
@@ -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<string, DisplayEvent>();
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<string, string[]> = {
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<string, string>;
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;
}
+7 -2
View File
@@ -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,
+92 -9
View File
@@ -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<string, string>();
const usedTargets = new Set<string>();
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);
}
+3 -2
View File
@@ -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),
+2
View File
@@ -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))
+15 -1
View File
@@ -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")
+26 -1
View File
@@ -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;
}
+4 -3
View File
@@ -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();
+6 -4
View File
@@ -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 });
+3 -2
View File
@@ -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(/<button[^>]*disabled[^>]*data-st/);
});
+132
View File
@@ -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<string, unknown>)?.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);
});
+34
View File
@@ -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);
});
+26
View File
@@ -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(<GameSettings state={s} sources={[]} initialGame="genshin" tab="about" now={Date.parse(at)} onAction={async () => ({ ok: true })} />);
expect(html).toContain("Track TCG events");
});