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:
1 parent
59403ca2cc
commit
2924605bd4
13 files changed
+296
-90
No files matched your search
+39
-8
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user