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
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user