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
+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);
}
/**