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:
Lucas Winther
2026-10-14 17:46:31 +02:00
co-authored by Claude Opus 5.5
parent b1a3de7690
commit 7c8fa7febc
9 changed files with 400 additions and 43 deletions
+14
View File
@@ -115,6 +115,20 @@ completion mark every user has ever made, silently, with no error and no way to
server-side (the server never had the data). If the scheme must change, ship a client-side
migration that reads the old keys and remaps them, and keep that migration for at least a year.
**A parser fix can move IDs too, and gets the same treatment.** `src/client/state/retiredIds.ts`
holds an explicit table of IDs the feed stopped publishing and what each became. On every load and
every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one:
- **A move inside the same storage key, not a copy, and not a deleted key.** The mark survives under
the ID the feed now uses. A copy left under the old ID would be carried forward again on each load
and bring back a mark the reader had since cleared.
- **When both IDs hold an entry**, each store's import merge decides — days are unioned, the earlier
ignore stands — except that progress keeps the later record's fields *and* any field only the other
held, because both rows were the reader's own.
- **An unreadable entry is left where it is.** These run in `useState` initialisers, where a throw is
a blank screen.
- **A table, never a pattern**, and each entry kept for at least a year.
The date suffix disambiguates reruns of the same event. Title is slugified from the *source's*
title, so a wiki renaming an event creates a new ID — reconciliation detects this as a near-match
(same game, overlapping dates, high title similarity) and treats it as an update rather than a new
@@ -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.
+5 -3
View File
@@ -1,4 +1,5 @@
import type { CustomEvents, CustomGames } from "../../shared/custom.ts";
import { carryDaily, carryMarks, carryProgress } from "./retiredIds.ts";
import type { DailyLogMap } from "./useDailyLog.ts";
import type { Prefs } from "./usePrefs.ts";
import type { ProgressMap } from "./useProgress.ts";
@@ -110,10 +111,11 @@ export function parseImportData(parsed: unknown): ParsedImport | null {
const prefs =
typeof data.prefs === "object" && data.prefs !== null ? data.prefs : null;
// A file exported before an event id moved still names the old one.
return {
progress,
daily,
ignored,
progress: progress === null ? null : carryProgress(progress),
daily: daily === null ? null : carryDaily(daily),
ignored: ignored === null ? null : carryMarks(ignored),
customGames: data.customGames,
customEvents: data.customEvents,
tasks: data.tasks,
+83
View File
@@ -0,0 +1,83 @@
import { mergeDailyLogs, type DailyLogMap } from "./useDailyLog.ts";
import { mergeMarks, type Marks } from "./useMarkSet.ts";
import { mergeProgress, type ProgressMap } from "./useProgress.ts";
/** Old id → the id its marks now belong to. */
export type CarryTable = Readonly<Record<string, string>>;
/**
* Event ids the feed no longer publishes, and the id each one became.
*
* An event id is a localStorage key (AGENTS.md § Event IDs are localStorage
* keys), so an id that moves takes the reader's marks with it unless they are
* carried across. A change that moves ids — a parser fix that renames a title
* is the usual one — lands its entries here, in the same commit.
*
* An explicit table, never a pattern: a pattern would also catch an id nobody
* retired. Keep each entry for at least a year — a reader who has not opened
* the app since still has marks under the old id.
*/
export const RETIRED_IDS: CarryTable = {};
/**
* Move each entry under an old id in `table` to its replacement.
*
* A move, not a copy: the storage key is untouched, and an entry left behind
* under the old id would be carried forward again on every load — bringing back
* a mark the reader had since cleared. Where both ids hold an entry (the reader
* marked both duplicate rows), `combine` is the store's own import merge, so
* nothing either row held is lost. Returns `map` itself when nothing moved.
*/
export function carryForward<V>(
map: Record<string, V>,
combine: (current: V, moved: V) => V,
table: CarryTable = RETIRED_IDS,
): Record<string, V> {
// These run in useState initialisers, where a throw is a blank screen, and
// `readJson` hands back a stored `null` as it found it. Leave what cannot be
// read exactly where it is.
if (!isRecord(map)) return map;
if (!Object.keys(map).some((id) => Object.hasOwn(table, id))) return map;
const next = { ...map };
for (const [from, to] of Object.entries(table)) {
const moved = next[from];
if (moved === undefined || !isRecord(moved)) continue;
delete next[from];
const current = next[to];
next[to] =
current !== undefined && isRecord(current) ? combine(current, moved) : moved;
}
return next;
}
function isRecord(v: unknown): boolean {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** One entry through a whole-map merge: the store's rule, not a second one. */
function viaMap<V>(merge: (a: Record<string, V>, b: Record<string, V>) => Record<string, V>) {
return (current: V, moved: V): V => merge({ id: current }, { id: moved }).id ?? current;
}
/**
* Not `mergeProgress` alone. That rule picks one whole record because an
* import is a foreign copy of the same row; here both are rows the reader
* edited, so the later one wins every field it sets and a note or goal only
* the other held survives rather than vanishing with the duplicate.
*/
export const carryProgress = (map: ProgressMap, table?: CarryTable): ProgressMap =>
carryForward(
map,
(current, moved) => {
const later = viaMap(mergeProgress)(current, moved);
const earlier = later === current ? moved : current;
return { ...earlier, ...later };
},
table,
);
export const carryDaily = (map: DailyLogMap, table?: CarryTable): DailyLogMap =>
carryForward(map, viaMap(mergeDailyLogs), table);
export const carryMarks = (map: Marks, table?: CarryTable): Marks =>
carryForward(map, viaMap(mergeMarks), table);
+30 -24
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { carryDaily } from "./retiredIds.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
export interface DailyLog {
@@ -22,7 +23,7 @@ export type DailyLogMap = Record<string, DailyLog>;
*/
export function useDailyLog() {
const [logs, setLogs] = useState<DailyLogMap>(() =>
readJson<DailyLogMap>(KEYS.daily, {}),
carryDaily(readJson<DailyLogMap>(KEYS.daily, {})),
);
useEffect(() => {
@@ -48,31 +49,36 @@ export function useDailyLog() {
[logs],
);
/**
* Union merge on import: every day either side recorded is a day the reader
* actually played, so keeping both is the only answer that cannot lose one.
*/
const merge = useCallback((incoming: DailyLogMap) => {
setLogs((prev) => {
const next = { ...prev };
for (const [id, log] of Object.entries(incoming)) {
// An imported file is untrusted input; a malformed entry is skipped
// rather than allowed to take the store down with it.
if (!Array.isArray(log?.days)) continue;
const days = log.days.filter((d): d is string => typeof d === "string");
if (days.length === 0) continue;
const union = [...new Set([...(next[id]?.days ?? []), ...days])].sort();
const at = next[id]?.at;
const incomingAt =
typeof log.at === "string" ? log.at : new Date().toISOString();
next[id] = {
days: union,
at: at === undefined || incomingAt > at ? incomingAt : at,
};
}
return next;
});
setLogs((prev) => mergeDailyLogs(prev, incoming));
}, []);
return { logs, toggleDay, daysFor, merge };
}
/**
* Union merge on import: every day either side recorded is a day the reader
* actually played, so keeping both is the only answer that cannot lose one.
*/
export function mergeDailyLogs(
current: DailyLogMap,
incoming: DailyLogMap,
): DailyLogMap {
const next = { ...current };
for (const [id, log] of Object.entries(incoming)) {
// An imported file is untrusted input; a malformed entry is skipped
// rather than allowed to take the store down with it.
if (!Array.isArray(log?.days)) continue;
const days = log.days.filter((d): d is string => typeof d === "string");
if (days.length === 0) continue;
const union = [...new Set([...(next[id]?.days ?? []), ...days])].sort();
const at = next[id]?.at;
const incomingAt =
typeof log.at === "string" ? log.at : new Date().toISOString();
next[id] = {
days: union,
at: at === undefined || incomingAt > at ? incomingAt : at,
};
}
return next;
}
+17 -14
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { carryMarks } from "./retiredIds.ts";
import { readJson, writeJson } from "./storage.ts";
export interface Mark {
@@ -17,7 +18,7 @@ export type Marks = Record<string, Mark>;
*/
export function useMarkSet(storageKey: string) {
const [marks, setMarks] = useState<Marks>(() =>
readJson<Marks>(storageKey, {}),
carryMarks(readJson<Marks>(storageKey, {})),
);
useEffect(() => {
@@ -36,21 +37,23 @@ export function useMarkSet(storageKey: string) {
const clear = useCallback(() => setMarks({}), []);
/**
* Union merge, keeping the earlier mark. Never removes: nothing else holds a
* copy of these, so a silent deletion would be unrecoverable.
*/
const merge = useCallback((incoming: Marks) => {
setMarks((prev) => {
const next = { ...prev };
for (const [id, value] of Object.entries(incoming)) {
const existing = next[id];
next[id] =
existing === undefined || value.at < existing.at ? value : existing;
}
return next;
});
setMarks((prev) => mergeMarks(prev, incoming));
}, []);
return { marks, toggle, merge, clear };
}
/**
* Union merge, keeping the earlier mark. Never removes: nothing else holds a
* copy of these, so a silent deletion would be unrecoverable.
*/
export function mergeMarks(current: Marks, incoming: Marks): Marks {
const next = { ...current };
for (const [id, value] of Object.entries(incoming)) {
const existing = next[id];
next[id] =
existing === undefined || value.at < existing.at ? value : existing;
}
return next;
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { Effort } from "../../shared/effort.ts";
import type { Goal } from "../../shared/goal.ts";
import { carryProgress } from "./retiredIds.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
import type { Marks } from "./useMarkSet.ts";
@@ -46,14 +47,14 @@ export type ProgressMap = Record<string, Progress>;
*/
function load(): ProgressMap {
const stored = readJson<ProgressMap>(KEYS.progress, {});
if (Object.keys(stored).length > 0) return stored;
if (Object.keys(stored).length > 0) return carryProgress(stored);
const legacy = readJson<Marks>(KEYS.completions, {});
const seeded: ProgressMap = {};
for (const [id, mark] of Object.entries(legacy)) {
seeded[id] = { status: "done", at: mark.at };
}
return seeded;
return carryProgress(seeded);
}
/**
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import {
carryForward,
carryDaily,
carryMarks,
carryProgress,
} from "../src/client/state/retiredIds.ts";
const OLD = "genshin:old-name:2026-09-23";
const NEW = "genshin:new-name:2026-09-23";
const T = { [OLD]: NEW };
describe("carryForward", () => {
test("moves an entry to the id the feed now uses", () => {
const out = carryForward({ [OLD]: { n: 1 } }, (a) => a, T);
expect(out).toEqual({ [NEW]: { n: 1 } });
});
test("leaves ids nobody retired alone, and returns the same object", () => {
const map = { "genshin:stygian-onslaught:2026-09-30": { n: 1 } };
expect(carryForward(map, (a) => a, T)).toBe(map);
});
test("a corrupt store or entry never takes the app down", () => {
// `readJson` returns a parsed `null` as it found it, and these run inside
// useState initialisers, where a throw is a blank screen.
expect(carryMarks(null as never, T)).toBe(null as never);
const out = carryMarks(
{ [OLD]: null as never, [NEW]: { at: "2026-09-24T00:00:00.000Z" } },
T,
);
expect(out[NEW]).toEqual({ at: "2026-09-24T00:00:00.000Z" });
});
test("is idempotent", () => {
const once = carryForward({ [OLD]: { n: 1 }, x: { n: 2 } }, (a) => a, T);
expect(carryForward(once, (a) => a, T)).toEqual(once);
});
});
describe("when the reader marked both rows", () => {
test("progress keeps whichever was touched last", () => {
const out = carryProgress(
{
[OLD]: { status: "done", at: "2026-10-02T00:00:00.000Z" },
[NEW]: { status: "doing", at: "2026-10-01T00:00:00.000Z" },
},
T,
);
expect(out).toEqual({
[NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" },
});
});
test("progress keeps a field only the older row held", () => {
// Both rows are the reader's own, so the later one wins each field it
// sets — but a note or a goal it never had is not something they removed.
const out = carryProgress(
{
[OLD]: { status: "doing", note: "farm weekly", at: "2026-09-25T00:00:00.000Z" },
[NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" },
},
T,
);
expect(out).toEqual({
[NEW]: { status: "done", note: "farm weekly", at: "2026-10-02T00:00:00.000Z" },
});
});
test("the daily log keeps every day either row was ticked", () => {
const out = carryDaily(
{
[OLD]: { days: ["2026-09-24", "2026-09-26"], at: "2026-09-26T00:00:00.000Z" },
[NEW]: { days: ["2026-09-25", "2026-09-26"], at: "2026-09-25T00:00:00.000Z" },
},
T,
);
expect(out).toEqual({
[NEW]: {
days: ["2026-09-24", "2026-09-25", "2026-09-26"],
at: "2026-09-26T00:00:00.000Z",
},
});
});
test("an ignore keeps the earlier mark", () => {
const out = carryMarks(
{
[OLD]: { at: "2026-09-24T00:00:00.000Z" },
[NEW]: { at: "2026-09-30T00:00:00.000Z" },
},
T,
);
expect(out).toEqual({ [NEW]: { at: "2026-09-24T00:00:00.000Z" } });
});
});