diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 5aa5cc0..ad4997f 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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 diff --git a/docs/superpowers/plans/2026-10-14-genshin-duplicates.md b/docs/superpowers/plans/2026-10-14-genshin-duplicates.md new file mode 100644 index 0000000..15784cb --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-genshin-duplicates.md @@ -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. diff --git a/docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md b/docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md new file mode 100644 index 0000000..b6d97c6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md @@ -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. diff --git a/src/client/state/export.ts b/src/client/state/export.ts index 0b31f0f..4966bb4 100644 --- a/src/client/state/export.ts +++ b/src/client/state/export.ts @@ -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, diff --git a/src/client/state/retiredIds.ts b/src/client/state/retiredIds.ts new file mode 100644 index 0000000..0311856 --- /dev/null +++ b/src/client/state/retiredIds.ts @@ -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>; + +/** + * 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( + map: Record, + combine: (current: V, moved: V) => V, + table: CarryTable = RETIRED_IDS, +): Record { + // 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(merge: (a: Record, b: Record) => Record) { + 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); diff --git a/src/client/state/useDailyLog.ts b/src/client/state/useDailyLog.ts index 60f1efa..a9249d1 100644 --- a/src/client/state/useDailyLog.ts +++ b/src/client/state/useDailyLog.ts @@ -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; */ export function useDailyLog() { const [logs, setLogs] = useState(() => - readJson(KEYS.daily, {}), + carryDaily(readJson(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; +} diff --git a/src/client/state/useMarkSet.ts b/src/client/state/useMarkSet.ts index bec8471..016fba7 100644 --- a/src/client/state/useMarkSet.ts +++ b/src/client/state/useMarkSet.ts @@ -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; */ export function useMarkSet(storageKey: string) { const [marks, setMarks] = useState(() => - readJson(storageKey, {}), + carryMarks(readJson(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; +} diff --git a/src/client/state/useProgress.ts b/src/client/state/useProgress.ts index e0a5c86..b8f40db 100644 --- a/src/client/state/useProgress.ts +++ b/src/client/state/useProgress.ts @@ -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; */ function load(): ProgressMap { const stored = readJson(KEYS.progress, {}); - if (Object.keys(stored).length > 0) return stored; + if (Object.keys(stored).length > 0) return carryProgress(stored); const legacy = readJson(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); } /** diff --git a/test/retired-ids.test.ts b/test/retired-ids.test.ts new file mode 100644 index 0000000..bdaed84 --- /dev/null +++ b/test/retired-ids.test.ts @@ -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" } }); + }); +});