feat(feed): name the ids merge absorbed, and carry marks off them

When merge fuses two sources' copies, the loser's id leaves the calendar, and it may already hold a reader's ticks from a day it was published alone. Until now that needed a hand-edited RETIRED_IDS entry per fusion.

The survivor now lists what it absorbed in an optional mergedIds field. When a feed arrives the client turns those into carry-forward pairs, skipping any id the feed still publishes, and an import takes the same table. Optional, so an older cached feed still validates and an older bundle drops it unread. RETIRED_IDS stays for parser changes, whose old ids no feed will name again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 17:48:15 +02:00
1 parent 59403ca2cc
commit 2924605bd4
13 files changed
+296 -90

No files matched your search

+5
View File
@@ -789,6 +789,11 @@ source CI can refresh. Four things to know about it:
label so both sources mint one ID, and `src/client/state/retiredIds.ts` moves readers' marks off
the two IDs that changed. The general lesson: **a second source needs its whole overlap with the
first read row by row, not counted** — a duplicate looks exactly like a net-new event in a tally.
- **A fusion carries readers' ticks with it, unaided.** Merge lists every id it absorbed on the
survivor (`mergedIds`), the feed ships it, and the client moves marks off those ids when a feed
arrives (`aliasesFrom`, `retiredIds.ts`). So a new merge rule, or a copy that starts matching
next week, needs no hand-edited table. `RETIRED_IDS` is only for an id a *parser* change moved —
the old id is never produced again, so no feed can name it.
- **The 336-day event raised the duration ceiling to 365 days.** Genshin's anniversary 5-star selection
runs 336 days (`2025-10-22` → `2026-09-23`), correctly dated on this wiki. This motivated raising the
sanity ceiling in `test/adapters/game8.test.ts` and `docs/INGESTION.md` from 180 to 365 days.
+21 -2
View File
@@ -60,6 +60,10 @@ export const GachaEvent = z.object({
version: z.number().int().positive(),
firstSeenAt: z.string().datetime(),
updatedAt: z.string().datetime(),
// Other sources' copies merge folded into this one; the client moves marks
// off them. Optional — see § ID stability.
mergedIds: z.array(z.string()).optional(),
});
export type GachaEvent = z.infer<typeof GachaEvent>;
@@ -119,7 +123,15 @@ migration that reads the old keys and remaps them, and keep that migration for a
holds an explicit table of IDs the feed stopped publishing and what each became — first used on
2026-10-14, when Game8's Genshin parser stopped keeping `Battle Pass -` in front of a pass's name
(the label had put every pass on the calendar twice beside Fandom's bare name). On every load and
every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one:
every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one.
**A merge that starts fusing two published IDs retires one of them too — and the feed says so.**
`GachaEvent.mergedIds` lists every other source's copy merge folded into an event. When a feed
arrives, `aliasesFrom` turns those into the same kind of move, skipping any id the feed still
publishes as its own row (a pair split again keeps each row's marks), so no table is edited by hand.
`RETIRED_IDS` stays only for parser changes, whose old ids no feed will ever name again. The field is
optional: an older cached feed lacks it and validates, and an older bundle's non-strict parse drops
it unread. Both paths follow the same rules:
- **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
@@ -129,7 +141,14 @@ every import, `progress`, `daily` and `ignored` entries under a retired ID **mov
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 kept until at least 2027-10-14.
- **A table, never a pattern**, and kept until at least 2027-10-14 — for `RETIRED_IDS`; the feed's
table is rebuilt from every feed.
- **A wrong fusion takes the tick with it, and nothing undoes that.** A move keeps no record of
where a mark began, so if merge ever fuses two different events, the absorbed row's tick lands on
the survivor and stays there if a later rule splits them again (reviewed 2026-10-14, accepted as
the price of not resurrecting cleared marks). The only defence is that merge rules stay strict —
which is why dates alone never pair two events (`docs/INGESTION.md` § Stage 3). An import is
carried through the same two tables on its way in.
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
+4
View File
@@ -486,6 +486,10 @@ Only meaningful when a game has more than one source; a single-source game passe
across sources — an identical id is the same row seen twice, whatever it is called.
2. **Near match** — **different sources**, same game, title similarity ≥ 0.80, starts within 24h —
→ same event under different titles; keep the higher-confidence copy.
**The survivor lists what it absorbed** in `mergedIds`, and the client moves readers' marks off
those ids when the feed arrives — so a rule that starts fusing two published ids costs nobody a
tick, with no table to edit (`docs/DATA-MODEL.md` § ID stability).
3. **Otherwise** → distinct events; keep both.
Title similarity alone would merge a rerun with its original, since reruns reuse the name. The
+39 -8
View File
@@ -12,10 +12,16 @@ import { Colophon } from "./components/Colophon.tsx";
import { Legend } from "./components/Legend.tsx";
import { Toast } from "./components/Toast.tsx";
import { UpdateNotice } from "./components/UpdateNotice.tsx";
import {
aliasesFrom,
carryDaily,
carryMarks,
carryProgress,
} from "./state/retiredIds.ts";
import { KEYS } from "./state/storage.ts";
import { useAppUpdate } from "./state/useAppUpdate.ts";
import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts";
import { useMarkSet, type Marks } from "./state/useMarkSet.ts";
import { useProgress, type ProgressMap } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { useTasks } from "./state/useTasks.ts";
import { useAmounts } from "./state/useAmounts.ts";
@@ -298,6 +304,31 @@ export function App() {
return () => ac.abort();
}, []);
// Merge may have folded another source's copy into a row since this reader
// last looked, and that copy's id may hold their ticks. The feed names every
// id it absorbed, so the marks follow with nobody editing a table
// (docs/DATA-MODEL.md § ID stability).
const feedEvents = state.status === "ready" ? state.feed.events : null;
const feedCarry = useMemo(
() => (feedEvents === null ? {} : aliasesFrom(feedEvents)),
[feedEvents],
);
const carryProgressIds = prog.carry;
const carryDailyIds = daily.carry;
const carryIgnoredIds = ignored.carry;
useEffect(() => {
if (Object.keys(feedCarry).length === 0) return;
carryProgressIds(feedCarry);
carryDailyIds(feedCarry);
carryIgnoredIds(feedCarry);
}, [feedCarry, carryProgressIds, carryDailyIds, carryIgnoredIds]);
// An export written before a fusion still names the absorbed id, and the
// effect above has already run for this feed — so an import takes the same
// table on its way in.
const importProgressMerge = (c: ProgressMap) => prog.merge(carryProgress(c, feedCarry));
const importDailyMerge = (c: DailyLogMap) => daily.merge(carryDaily(c, feedCarry));
const importIgnoredMerge = (c: Marks) => ignored.merge(carryMarks(c, feedCarry));
const allRows = useMemo<RowEvent[]>(() => {
if (state.status !== "ready") return [];
// The reader's own events are events. They sort, filter, focus, expire and
@@ -840,9 +871,9 @@ export function App() {
onImport={(file) =>
void importProgress(
file,
prog.merge,
daily.merge,
ignored.merge,
importProgressMerge,
importDailyMerge,
importIgnoredMerge,
custom.merge,
mergeExtra,
)
@@ -858,9 +889,9 @@ export function App() {
onImportAll={(file) =>
void importProgress(
file,
prog.merge,
daily.merge,
ignored.merge,
importProgressMerge,
importDailyMerge,
importIgnoredMerge,
custom.merge,
mergeExtra,
restorePrefs,
+29 -1
View File
@@ -6,7 +6,7 @@ import { mergeProgress, type ProgressMap } from "./useProgress.ts";
export type CarryTable = Readonly<Record<string, string>>;
/**
* Event ids the feed no longer publishes, and the id each one became.
* Event ids a *parser* change stopped publishing, 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
@@ -15,6 +15,11 @@ export type CarryTable = Readonly<Record<string, string>>;
* published the bare name, and the two ids put every pass on the calendar
* twice (docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md).
*
* Only a parser change belongs here. An id that *merge* folds into another is
* named by the feed itself (`GachaEvent.mergedIds`) and carried by
* `aliasesFrom` with nobody editing anything; a parser change cannot be,
* because the old id is never produced again for any feed to mention.
*
* An explicit table, never a pattern: the battle passes are the only two such
* ids that ever shipped (git history of snapshots/ and fixtures/), and a
* pattern would also catch an id nobody retired. Keep it until at least
@@ -26,6 +31,29 @@ export const RETIRED_IDS: CarryTable = {
"genshin:battle-pass-moontrace:2026-09-23": "genshin:moontrace:2026-09-23",
};
/**
* What the feed says merge absorbed, as a carry table.
*
* Every id an event lists in `mergedIds` was another source's copy of it, and
* may already hold a reader's marks from a day it was published alone. Two
* exclusions: an id the feed still publishes as its own row — should a later
* feed split a pair again, each row keeps its own marks — and an event naming
* itself, which would be a no-op at best.
*/
export function aliasesFrom(
events: ReadonlyArray<{ id: string; mergedIds?: readonly string[] | undefined }>,
): CarryTable {
const published = new Set(events.map((e) => e.id));
const table: Record<string, string> = {};
for (const event of events) {
for (const from of event.mergedIds ?? []) {
if (from === event.id || published.has(from)) continue;
table[from] = event.id;
}
}
return table;
}
/**
* Move each entry under an old id in `table` to its replacement.
*
+7 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { carryDaily } from "./retiredIds.ts";
import { carryDaily, type CarryTable } from "./retiredIds.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
export interface DailyLog {
@@ -53,7 +53,12 @@ export function useDailyLog() {
setLogs((prev) => mergeDailyLogs(prev, incoming));
}, []);
return { logs, toggleDay, daysFor, merge };
/** Move logs off ids the feed says were folded into others. */
const carry = useCallback((table: CarryTable) => {
setLogs((prev) => carryDaily(prev, table));
}, []);
return { logs, toggleDay, daysFor, merge, carry };
}
/**
+7 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { carryMarks } from "./retiredIds.ts";
import { carryMarks, type CarryTable } from "./retiredIds.ts";
import { readJson, writeJson } from "./storage.ts";
export interface Mark {
@@ -41,7 +41,12 @@ export function useMarkSet(storageKey: string) {
setMarks((prev) => mergeMarks(prev, incoming));
}, []);
return { marks, toggle, merge, clear };
/** Move marks off ids the feed says were folded into others. */
const carry = useCallback((table: CarryTable) => {
setMarks((prev) => carryMarks(prev, table));
}, []);
return { marks, toggle, merge, clear, carry };
}
/**
+7 -1
View File
@@ -1,7 +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 { carryProgress, type CarryTable } from "./retiredIds.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
import type { Marks } from "./useMarkSet.ts";
@@ -211,6 +211,11 @@ export function useProgress() {
setProgress((prev) => mergeProgress(prev, incoming));
}, []);
/** Move records off ids the feed says were folded into others. */
const carry = useCallback((table: CarryTable) => {
setProgress((prev) => carryProgress(prev, table));
}, []);
return {
progress,
patch,
@@ -220,5 +225,6 @@ export function useProgress() {
setEffort,
setNote,
merge,
carry,
};
}
+20 -1
View File
@@ -83,10 +83,11 @@ export function mergeEvents(
if (existing === undefined) continue;
const conflict = findConflict(existing, incoming, toleranceHours);
const [winner, loser] =
const [ranked, loser] =
incoming.confidence > existing.confidence
? ([incoming, existing] as const)
: ([existing, incoming] as const);
const winner = absorb(ranked, loser);
if (conflict !== null) {
// Independent sources disagree on when this ends. Do not average, do not
@@ -116,6 +117,24 @@ export function mergeEvents(
return { events: kept, conflicts };
}
/**
* The winner, carrying every id the fusion took off the calendar.
*
* An id merge drops may already be on readers' devices as a localStorage key —
* the copy fused today may have been published alone yesterday, before a rule
* learned to pair it. The feed says which ids were folded into which, and the
* client moves marks across on its own (`src/client/state/retiredIds.ts`), so a
* new fusion never needs a hand-edited table to keep a reader's ticks.
*/
function absorb(winner: GachaEvent, loser: GachaEvent): GachaEvent {
const ids = [
...(winner.mergedIds ?? []),
...(loser.id === winner.id ? [] : [loser.id]),
...(loser.mergedIds ?? []),
].filter((id, i, all) => id !== winner.id && all.indexOf(id) === i);
return ids.length === 0 ? winner : { ...winner, mergedIds: ids };
}
function isSameEvent(
a: GachaEvent,
b: GachaEvent,
+9
View File
@@ -76,6 +76,15 @@ export const GachaEvent = z
version: z.number().int().positive(),
firstSeenAt: z.string().datetime(),
updatedAt: z.string().datetime(),
/**
* Ids of other sources' copies merge folded into this one. They may already
* be readers' localStorage keys, so the client moves marks from them to
* `id` (docs/DATA-MODEL.md § ID stability). Optional: absent on every
* single-source event and on any feed built before 2026-10-14, and an older
* client's non-strict parse drops it unread.
*/
mergedIds: z.array(z.string()).optional(),
})
// These invariants are cheap here and prevent a whole class of wrong-date bugs
// reaching the feed. See docs/INGESTION.md § Stage 4.
+30 -1
View File
@@ -8,7 +8,11 @@ import {
SourceHealth,
} from "../src/shared/feed.ts";
import { sourceHealth } from "../src/ingest/health.ts";
import type { GachaEvent, GameId } from "../src/shared/schema.ts";
import {
GachaEvent as GachaEventSchema,
type GachaEvent,
type GameId,
} from "../src/shared/schema.ts";
/**
* Freshness disclosure (PRD F7).
@@ -430,3 +434,28 @@ describe("recording a source's health at build time", () => {
expect(health.lastConfirmedAt).toBe(NEW_CONTENT);
});
});
describe("mergedIds in the feed", () => {
// The service worker serves the last feed it downloaded, and an open tab may
// run a bundle from before this field existed — both directions must parse.
const sample = async () =>
(
(await Bun.file(
"fixtures/genshin/game8-events-2026-08-14.expected.json",
).json()) as unknown[]
)[0] as Record<string, unknown>;
test("an event without it still validates", async () => {
expect(GachaEventSchema.safeParse(await sample()).success).toBe(true);
});
test("an event with it keeps it", async () => {
const parsed = GachaEventSchema.safeParse({
...(await sample()),
mergedIds: ["genshin:other:2026-08-12"],
});
expect(parsed.success && parsed.data.mergedIds).toEqual([
"genshin:other:2026-08-12",
]);
});
});
+24
View File
@@ -190,6 +190,30 @@ describe("merging two sources for one game", () => {
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.field).toBe("endsAt");
});
test("the survivor lists every id it absorbed", () => {
const a = event({ id: "genshin:stygian-onslaught:2026-08-12", title: "Stygian Onslaught", sourceId: "s-a" });
const b = event({ id: "genshin:stygian-onslaught-event:2026-08-12", title: "Stygian Onslaught Event", sourceId: "s-b" });
const c = event({ id: "genshin:stygian-onslaught-trial:2026-08-12", title: "Stygian Onslaught Trial", sourceId: "s-c" });
const { events } = mergeEvents([[a], [b], [c]]);
expect(events).toHaveLength(1);
expect(events[0]?.mergedIds).toEqual([b.id, c.id]);
});
test("a higher-confidence newcomer inherits what the old copy absorbed", () => {
const a = event({ id: "genshin:x-y:2026-08-12", title: "X Y", sourceId: "s-a", confidence: 0.8 });
const b = event({ id: "genshin:x-y-z:2026-08-12", title: "X Y Z", sourceId: "s-b", confidence: 0.8 });
const c = event({ id: "genshin:x-y-w:2026-08-12", title: "X Y W", sourceId: "s-c", confidence: 0.95 });
const { events } = mergeEvents([[a], [b], [c]]);
expect(events.map((e) => e.id)).toEqual([c.id]);
expect([...(events[0]?.mergedIds ?? [])].sort()).toEqual([a.id, b.id].sort());
});
test("an id seen twice is not an absorbed id", () => {
const a = event({ sourceId: "s-a" });
const { events } = mergeEvents([[a], [{ ...a, sourceId: "s-b" }]]);
expect(events[0]?.mergedIds).toBeUndefined();
});
});
describe("near matches within one source", () => {
+94 -72
View File
@@ -2,53 +2,66 @@ import { describe, expect, test } from "bun:test";
import { parseImportData } from "../src/client/state/export.ts";
import {
RETIRED_IDS,
aliasesFrom,
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 };
const OLD = "genshin:battle-pass-moontrace:2026-09-23";
const NEW = "genshin:moontrace:2026-09-23";
describe("RETIRED_IDS", () => {
test("names the two battle passes Game8 minted with their label", () => {
expect(RETIRED_IDS[OLD]).toBe(NEW);
expect(RETIRED_IDS["genshin:battle-pass-frostfarer:2026-08-12"]).toBe(
"genshin:frostfarer:2026-08-12",
);
});
test("holds only what a parser moved; merges arrive through the feed", () => {
expect(Object.keys(RETIRED_IDS)).toHaveLength(2);
});
test("never retires an id onto another retired id", () => {
for (const to of Object.values(RETIRED_IDS)) {
expect(RETIRED_IDS[to]).toBeUndefined();
}
});
});
describe("carryForward", () => {
test("moves an entry to the id the feed now uses", () => {
const out = carryForward({ [OLD]: { n: 1 } }, (a) => a, T);
const out = carryForward({ [OLD]: { n: 1 } }, (a) => a);
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);
expect(carryForward(map, (a) => a)).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(carryMarks(null as never)).toBe(null as never);
const out = carryMarks({ [OLD]: null as never, [NEW]: { at: "2026-09-24T00:00:00.000Z" } });
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);
const once = carryForward({ [OLD]: { n: 1 }, x: { n: 2 } }, (a) => a);
expect(carryForward(once, (a) => a)).toEqual(once);
});
});
describe("when the reader marked both rows", () => {
describe("when the reader marked both duplicate 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,
);
const out = carryProgress({
[OLD]: { status: "done", at: "2026-10-02T00:00:00.000Z" },
[NEW]: { status: "doing", at: "2026-10-01T00:00:00.000Z" },
});
expect(out).toEqual({
[NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" },
});
@@ -57,26 +70,20 @@ describe("when the reader marked both rows", () => {
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,
);
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" },
});
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,
);
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" },
});
expect(out).toEqual({
[NEW]: {
days: ["2026-09-24", "2026-09-25", "2026-09-26"],
@@ -86,58 +93,73 @@ describe("when the reader marked both rows", () => {
});
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,
);
const out = carryMarks({
[OLD]: { at: "2026-09-24T00:00:00.000Z" },
[NEW]: { at: "2026-09-30T00:00:00.000Z" },
});
expect(out).toEqual({ [NEW]: { at: "2026-09-24T00:00:00.000Z" } });
});
});
const PASS = "genshin:battle-pass-moontrace:2026-09-23";
const BARE = "genshin:moontrace:2026-09-23";
describe("RETIRED_IDS", () => {
test("names the two battle passes Game8 minted with their label", () => {
expect(RETIRED_IDS[PASS]).toBe(BARE);
expect(RETIRED_IDS["genshin:battle-pass-frostfarer:2026-08-12"]).toBe(
"genshin:frostfarer:2026-08-12",
);
});
test("never retires an id onto another retired id", () => {
for (const to of Object.values(RETIRED_IDS)) {
expect(RETIRED_IDS[to]).toBeUndefined();
}
});
test("is the table a load uses when none is given", () => {
const out = carryMarks({ [PASS]: { at: "2026-09-24T00:00:00.000Z" } });
expect(Object.keys(out)).toEqual([BARE]);
});
});
describe("an export made before the ids moved", () => {
test("imports onto the new ids", () => {
const data = parseImportData({
format: "gacha-tracker-export",
progress: { [PASS]: { status: "done", at: "2026-10-01T00:00:00.000Z" } },
daily: { [PASS]: { days: ["2026-09-24"], at: "2026-09-24T00:00:00.000Z" } },
ignored: { [PASS]: { at: "2026-09-24T00:00:00.000Z" } },
progress: { [OLD]: { status: "done", at: "2026-10-01T00:00:00.000Z" } },
daily: { [OLD]: { days: ["2026-09-24"], at: "2026-09-24T00:00:00.000Z" } },
ignored: { [OLD]: { at: "2026-09-24T00:00:00.000Z" } },
});
expect(Object.keys(data?.progress ?? {})).toEqual([BARE]);
expect(Object.keys(data?.daily ?? {})).toEqual([BARE]);
expect(Object.keys(data?.ignored ?? {})).toEqual([BARE]);
expect(Object.keys(data?.progress ?? {})).toEqual([NEW]);
expect(Object.keys(data?.daily ?? {})).toEqual([NEW]);
expect(Object.keys(data?.ignored ?? {})).toEqual([NEW]);
});
test("so does one from before progress existed", () => {
const data = parseImportData({
format: "gacha-tracker-export",
completions: { [PASS]: { at: "2026-10-01T00:00:00.000Z" } },
completions: { [OLD]: { at: "2026-10-01T00:00:00.000Z" } },
});
expect(data?.progress?.[BARE]?.status).toBe("done");
expect(data?.progress?.[NEW]?.status).toBe("done");
});
});
describe("ids the feed says merge absorbed", () => {
const LIMITED = "genshin:free-limited-5-star-anniversary-selector:2026-09-23";
const TABLETOP = "genshin:tabletop-troupe-a-gathering-on-adventures-eve:2026-09-23";
test("become carry-forward pairs onto the surviving id", () => {
expect(aliasesFrom([{ id: LIMITED, mergedIds: [TABLETOP] }])).toEqual({
[TABLETOP]: LIMITED,
});
});
test("never move a mark off an id the feed still publishes", () => {
// Should a later feed split the pair again, both rows are back on the
// calendar and each keeps its own marks.
const table = aliasesFrom([
{ id: LIMITED, mergedIds: [TABLETOP] },
{ id: TABLETOP },
]);
expect(table).toEqual({});
});
test("ignore an event that lists itself", () => {
expect(aliasesFrom([{ id: LIMITED, mergedIds: [LIMITED] }])).toEqual({});
});
test("move a Fandom-selector tick onto Game8's row", () => {
const table = aliasesFrom([{ id: LIMITED, mergedIds: [TABLETOP] }]);
const out = carryProgress(
{ [TABLETOP]: { status: "done", at: "2026-10-01T00:00:00.000Z" } },
table,
);
expect(out).toEqual({
[LIMITED]: { status: "done", at: "2026-10-01T00:00:00.000Z" },
});
});
test("a table passed in replaces the hand table for that call", () => {
const out = carryMarks({ [OLD]: { at: "2026-09-24T00:00:00.000Z" } }, {});
expect(Object.keys(out)).toEqual([OLD]);
});
});