feat(client): move readers' marks off event ids that stop being published
An event id is a localStorage key, so a fix that changes one orphans every tick, note and ignore filed under it. retiredIds.ts holds an explicit table of old id -> new id, and the progress, daily and ignored stores move entries across on load and on import. It moves rather than copies: a copy left under the old id would be carried forward again on every load and bring back a mark the reader had cleared. When both ids hold an entry, each store's own import merge decides, except that progress keeps any field only the older row held. The table starts empty; the change that moves ids adds its entries. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b1a3de7690
commit
7c8fa7febc
@@ -0,0 +1,54 @@
|
||||
# Genshin battle-pass duplicate Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** One row per Genshin battle pass, with every reader's marks carried to the surviving ID.
|
||||
|
||||
**Architecture:** The Game8 parser drops a leading `Battle Pass -` label so both sources mint the
|
||||
same ID; `titleExtends` learns leading qualifiers; a pure remap table (`src/client/state/retiredIds.ts`)
|
||||
moves entries in the three event-keyed stores on load and on import.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Commits wait to be asked (AGENTS.md § Conventions); each task ends green.
|
||||
- No change to `slugify`, `eventId` or the feed schema.
|
||||
- The remap never drops data: moved entries merge under the store's own import rule.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **1. Parser.** Failing test in `test/adapters/game8.test.ts`: the Genshin fixture yields
|
||||
`Frostfarer` with id `genshin:frostfarer:2026-08-12`, and no title starts `Battle Pass`. Strip in
|
||||
`toEvent` (`src/ingest/parsers/game8.ts`). Regenerate `fixtures/genshin/game8-events-2026-08-14.expected.json`
|
||||
with `bun run parse … --json` and diff it: only the Frostfarer row may move.
|
||||
- [x] **2. Merge.** Failing tests in `test/merge.test.ts`: `titleExtends("Frozen Wilds", "Anniversary Frozen Wilds")`
|
||||
is true; a one-word base is still refused; `Gold Clash` / `Gold Rush Clash` still refused. Then a
|
||||
`mergeEvents` test fusing the two pinned Genshin fixtures' Frostfarer rows into one. Implement.
|
||||
- [x] **3. Remap.** `src/client/state/retiredIds.ts`: `RETIRED_IDS` table and
|
||||
`carryForward(map, combine)`. Tests in `test/retired-ids.test.ts`: move, both-present merge per
|
||||
store, idempotence, unrelated ids untouched. Wire into `useProgress` load, `useDailyLog` init,
|
||||
`useMarkSet` init, and `parseImportData`.
|
||||
- [x] **4. Verify.** `bun run typecheck`, `bun test`, rebuild the feed and diff Genshin IDs against
|
||||
the baseline: exactly one fewer row, only the battle-pass IDs moved. schema-guardian review.
|
||||
- [x] **6. Selectors** (added once the owner established the pairing). `src/ingest/aliases.ts`
|
||||
records each pair by event ID with its evidence; `isSameEvent` accepts it; Fandom's two IDs join
|
||||
`RETIRED_IDS`. Tests: fuses the recorded pair, not next year's run, not across pairs.
|
||||
- [x] **5. Docs.** AGENTS.md § Fandom (Genshin): the prefix, the corrected net-new count;
|
||||
`docs/DATA-MODEL.md` § ID stability: the retired-ID table; `docs/INGESTION.md` merge rules if they
|
||||
describe `titleExtends`.
|
||||
|
||||
## Part 2 tasks
|
||||
|
||||
- [x] **7. Summary rule.** Tests in `test/merge.test.ts`: fuses a pair whose summary names the other
|
||||
title; refuses when ends differ, when the named title is one word, when no summary names it. Empty
|
||||
`SAME_EVENT`; `recordedSame` takes a table so its test injects one.
|
||||
- [x] **8. mergedIds.** Merge test: survivor lists the absorbed id, chained fusions accumulate,
|
||||
same-id repeats add nothing. `GachaEvent.mergedIds` optional; feed parse accepts with and without.
|
||||
- [x] **9. Client carry from feed.** `aliasesFrom(events)` (skips published ids and self-maps);
|
||||
`carryForward(map, combine, table)`; a `carry(table)` on the three stores; one effect in `App`.
|
||||
Drop Fandom's selector ids from `RETIRED_IDS`.
|
||||
- [ ] **10. Verify.** typecheck, suite, feed diff (214, survivors carry `mergedIds`), browser check,
|
||||
schema-guardian.
|
||||
- [x] **11. Docs.** AGENTS.md selector bullet, INGESTION.md rule 2, DATA-MODEL.md `mergedIds` and
|
||||
the feed-carried move.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Genshin's battle pass listed twice — design
|
||||
|
||||
Date: 2026-10-14. Status: approved in conversation (widen merge, strip the prefix in the parser,
|
||||
build the remap). The anniversary-selector duplicate is a separate decision, open below.
|
||||
|
||||
## Problem
|
||||
|
||||
A reader reported duplicate Genshin events. Building the feed from the current snapshots puts one
|
||||
event on the calendar twice:
|
||||
|
||||
| Source | Title | ID | Dates |
|
||||
|---|---|---|---|
|
||||
| `genshin-game8-events` | `Battle Pass - Moontrace` | `genshin:battle-pass-moontrace:2026-09-23` | 09-23 → 11-02 |
|
||||
| `genshin-fandom-events` | `Moontrace` | `genshin:moontrace:2026-09-23` | 09-23 → 11-02 |
|
||||
|
||||
The pinned fixtures show the same pair for the previous pass (`Battle Pass - Frostfarer` /
|
||||
`Frostfarer`, 08-12 → 09-21), so every battle pass has been on the calendar twice since the Fandom
|
||||
source landed on 2026-09-12. The "11 net-new events" AGENTS.md recorded for that source counted one
|
||||
of these duplicates as new.
|
||||
|
||||
## Root cause
|
||||
|
||||
Game8 names the event's category in front of its name; Fandom puts the category in its `Type(s)`
|
||||
column. `mergeEvents` fuses a cross-source pair on title similarity ≥ 0.8 or on `titleExtends`, and
|
||||
neither fires: Dice similarity is 2·1/(3+1) = 0.5, and `titleExtends` only accepts words *appended*
|
||||
to a base of at least two words, while here one word was *prepended* to a base of one.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **The Game8 parser strips a leading `Battle Pass -` from a title.** The category is not the
|
||||
event's name, and both copies then mint the same ID, so merge rule 1 (same ID) fuses them with
|
||||
no fuzzy matching. Only Genshin's Game8 page has ever used this label (git history of
|
||||
`snapshots/` and `fixtures/`: Frostfarer and Moontrace only).
|
||||
2. **`titleExtends` also accepts words prepended**, under the same two-word floor. It is the general
|
||||
form of the same mistake — a source qualifier at the front instead of the back — and the floor is
|
||||
kept because a one-word base would fuse half a patch day. This rule does not catch Moontrace on
|
||||
its own; decision 1 does.
|
||||
3. **Readers' marks follow the ID.** Decision 1 moves Game8's two IDs, so a client-side remap moves
|
||||
`progress`, `daily` and `ignored` entries from each retired ID to its replacement, on every load
|
||||
and on every import. It is an explicit table, not a pattern, and stays until at least 2027-10-14.
|
||||
- **Move, not copy.** The localStorage *key* is untouched; the entry changes its id inside it. A
|
||||
copy that kept the old entry would re-create a mark the reader cleared on every load after.
|
||||
- **When both ids hold an entry, the store's own import merge decides** — progress: later `at`
|
||||
wins; daily: union of days; ignored: earlier mark. A reader who marked both duplicate rows
|
||||
loses nothing.
|
||||
- Idempotent: once moved, the retired id is absent and the remap does nothing.
|
||||
|
||||
## The anniversary selectors
|
||||
|
||||
Fandom lists the two anniversary 5-star selectors under their event names
|
||||
(`Tabletop Troupe: A Gathering on Adventure's Eve`, `Across the Frozen Wilds, Honing One's Edge`),
|
||||
both typed `Anniversary 5-Star Character Selection`, both 09-23 → 11-03. Game8 lists them as
|
||||
`Free Limited 5-Star Anniversary Selector` and `Free Standard 5-Star Anniversary Selector`. No title
|
||||
rule can pair them, and the dates cannot tell the two apart.
|
||||
|
||||
Approved: the owner establishes the pairing and it is recorded. They did, on 2026-10-14 — Game8's own
|
||||
summaries name the Fandom events (limited → Tabletop Troupe, standard → Frozen Wilds), and Fandom's
|
||||
articles agree (event-exclusive vs Standard Wish).
|
||||
|
||||
4. **`src/ingest/aliases.ts` records each pair by event ID**, with its evidence; `isSameEvent`
|
||||
accepts a recorded pair as similar and still applies the start-date guard. Keyed by ID so a record
|
||||
covers one run and lapses on its own; next year needs a new entry rather than inheriting a guess.
|
||||
5. **Game8's copy survives** (priority, as everywhere in this lane), so Fandom's two IDs join the
|
||||
retired table and move onto Game8's.
|
||||
|
||||
## Not changing
|
||||
|
||||
`slugify`, `eventId`, the feed schema, priority, and every other source's IDs. Verified by diffing
|
||||
the built feed before and after: 217 → 214 events, and the only IDs that stop being published are
|
||||
Game8's Moontrace and Fandom's two selectors — each in the retired table, each onto a row still on
|
||||
the calendar. No other game moved.
|
||||
|
||||
## Part 2 — so the next one needs nobody
|
||||
|
||||
Approved in conversation the same day: automate the two manual steps the selectors needed.
|
||||
|
||||
6. **A summary that names the other copy's title is evidence they are one event.** In
|
||||
`isSameEvent`, cross-source as ever: starts *and* ends within 24h, and one copy's summary contains
|
||||
the other's whole title as a contiguous run of at least two words. Game8's selector summaries
|
||||
("…the annual 5-star selector, Across the Frozen Wilds, Honing One's Edge!") are the case. Measured
|
||||
on every two-source page held (Genshin and Endfield, fixtures and snapshots): it fires on both
|
||||
selector pairs and otherwise only on pairs already matched, and on none of the ~25 distinct pairs
|
||||
sharing a window. Dates alone are refused: patch day puts ~20 different Genshin events on one
|
||||
window, so a same-dates rule or detector would be ten-to-one noise.
|
||||
7. **Merge records what it absorbed, and the feed carries it.** The surviving event gets
|
||||
`mergedIds` — every other id fused into it. Optional in `GachaEvent`, so an old cached feed still
|
||||
validates and an old shell strips it unread.
|
||||
8. **The client moves marks off absorbed ids on its own.** When a feed arrives, every `mergedIds`
|
||||
entry not itself published as an event becomes a carry-forward pair for `progress`, `daily` and
|
||||
`ignored`, under the same move/merge rules as `RETIRED_IDS`. A future fusion — from any rule —
|
||||
then needs no hand-edited table. `RETIRED_IDS` stays for ids a *parser* change moved, which no
|
||||
feed can describe because the old id is never produced again.
|
||||
9. **`aliases.ts` stays as the fallback** for copies with no shared word and no prose linking them,
|
||||
and its two selector entries come out: rule 6 now finds them from the pages, and two answers to
|
||||
one question is how rules drift. Fandom's two selector ids leave `RETIRED_IDS` for the same reason.
|
||||
|
||||
Verification: the built feed is unchanged at 214 events, the three survivors carry `mergedIds`, and a
|
||||
browser load moves a seeded Fandom-selector mark onto Game8's id via the feed.
|
||||
Reference in New Issue
Block a user