From b73c253de2dc062e87b92056bcf431854129fc78 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 17 Sep 2026 10:20:07 +0200 Subject: [PATCH] feat(ui): add "Export all" and "Import all" buttons for moving hosts --- docs/DATA-MODEL.md | 8 ++ src/client/App.tsx | 134 ++++++++++++----------------- src/client/components/Controls.tsx | 37 +++++++- test/controls.test.tsx | 14 +++ 4 files changed, 111 insertions(+), 82 deletions(-) diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index ade861e..cd215d0 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -606,6 +606,14 @@ simply has no `daily` key, which is not an error. Losing a user's marks to a bad deliberately one-directional. A file whose `format` is unrecognised is refused outright rather than partly applied. +A standard **Export** produces a progress backup containing progress, daily checklists, ignored +events, and custom games/events without `prefs`, so that importing progress onto another device +leaves that device's region, theme, and game order intact. **Export all** includes `prefs` alongside +progress in the export JSON. Standard **Import** merges progress and custom content while ignoring +`prefs`. **Import all** merges progress and custom content and also restores all preferences +(`region`, `hiddenGames`, `gameOrder`, `theme`, `view`, etc.) via `restorePrefsValue`, falling back +to defaults for any fields omitted by older exports — designed specifically for moving hosts. + ## Schema versioning `/api/events` responses carry `{ schemaVersion: 1, generatedAt, events: [...], sources: [...] }`. diff --git a/src/client/App.tsx b/src/client/App.tsx index cf45dba..e327e69 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -17,7 +17,8 @@ import { useAppUpdate } from "./state/useAppUpdate.ts"; import { useMarkSet } from "./state/useMarkSet.ts"; import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; -import { adoptNewLanes, usePrefs } from "./state/usePrefs.ts"; +import { adoptNewLanes, usePrefs, type Prefs } from "./state/usePrefs.ts"; +import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts"; import { snapDayWidth } from "./state/zoom.ts"; import { useCustom, type EventDraft } from "./state/useCustom.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; @@ -103,7 +104,7 @@ export function App() { const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null); const now = useNow(); const online = useOnline(); - const { prefs, update, toggleGame } = usePrefs(); + const { prefs, update, toggleGame, restorePrefs } = usePrefs(); // Their answer from the first run, or their last tap on the tabs. Reading it // from `prefs` is what stops a reload putting a timeline reader back on the // list they did not choose. @@ -658,7 +659,7 @@ export function App() { onOpen: setOpenId, }} onExport={() => - exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { + exportProgress(prog.progress, daily.logs, ignored.marks, { games: custom.games, events: custom.events, }) @@ -672,6 +673,22 @@ export function App() { custom.merge, ) } + onExportAll={() => + exportAll(prog.progress, daily.logs, ignored.marks, prefs, { + games: custom.games, + events: custom.events, + }) + } + onImportAll={(file) => + void importProgress( + file, + prog.merge, + daily.merge, + ignored.merge, + custom.merge, + restorePrefs, + ) + } /> {!online && ( @@ -897,49 +914,27 @@ function exportProgress( progress: Record, daily: DailyLogMap, ignored: Record, - prefs: unknown, own: { games: CustomGames; events: CustomEvents }, ) { - const blob = new Blob( - [ - JSON.stringify( - { - format: "gacha-tracker-export", - version: 1, - exportedAt: new Date().toISOString(), - progress, - // Streaks live nowhere else — not on a server, not in the feed — so - // an export that omitted them would quietly be a lossy backup. - daily, - ignored, - // The reader's own games and events exist nowhere else at all — not - // in the feed, not on a server. An export without them is a backup - // that quietly loses the half they typed themselves. - customGames: own.games, - customEvents: own.events, - prefs, - }, - null, - 2, - ), - ], - { type: "application/json" }, + const data = buildExportData(progress, daily, ignored, own); + triggerDownload( + data, + `event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`, + ); +} + +function exportAll( + progress: Record, + daily: DailyLogMap, + ignored: Record, + prefs: Prefs, + own: { games: CustomGames; events: CustomEvents }, +) { + const data = buildExportData(progress, daily, ignored, own, prefs); + triggerDownload( + data, + `event-clock-backup-${new Date().toISOString().slice(0, 10)}.json`, ); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`; - // In the document, and revoked on a later task. This is the only copy of - // everything the reader typed, ticked and marked — there is no account and no - // server that has ever seen it — so a download that quietly does not happen is - // the lossy backup this function exists to prevent. A detached anchor is not - // reliably clickable, and revoking the URL in the same task can pull the blob - // out from under a download that had not started reading it yet. - a.style.display = "none"; - document.body.append(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 0); } async function importProgress( @@ -948,50 +943,29 @@ async function importProgress( mergeDaily: (c: DailyLogMap) => void, mergeIgnored: (c: Record) => void, mergeCustom: (games: unknown, events: unknown) => void, + restorePrefs?: ((prefs: unknown) => void) | undefined, ) { try { - const parsed: unknown = JSON.parse(await file.text()); - const data = parsed as { - format?: string; - progress?: unknown; - completions?: unknown; - daily?: unknown; - ignored?: unknown; - customGames?: unknown; - customEvents?: unknown; - }; - if (data.format !== "gacha-tracker-export") { + const text = await file.text(); + const data = parseImportData(JSON.parse(text)); + if (data === null) { alert("That file isn't an Event Clock export."); return; } - const asMarks = (v: unknown) => - typeof v === "object" && v !== null - ? (v as Record) - : null; - // Accept exports from before progress replaced completions: membership - // there meant "done", so map it forward rather than dropping it. - const p = asMarks(data.progress); - const legacy = asMarks(data.completions); - const i = asMarks(data.ignored); - if (p !== null) mergeProgress(p); - else if (legacy !== null) { - mergeProgress( - Object.fromEntries( - Object.entries(legacy).map(([id, m]) => [id, { ...m, status: "done" }]), - ), - ); - } - // An export written before daily checklists existed simply has no `daily` - // key; that is not an error, it just leaves the streaks it never held. - const d = data.daily; - if (typeof d === "object" && d !== null) mergeDaily(d as DailyLogMap); - if (i !== null) mergeIgnored(i); - // Additive keys: an export written before F13 has neither, which is a file - // from a device that had none rather than an error. An event and the game - // it belongs to always travel together, so this can never land a lane with - // nothing to name it. + if (data.progress !== null) mergeProgress(data.progress); + if (data.daily !== null) mergeDaily(data.daily); + if (data.ignored !== null) mergeIgnored(data.ignored); mergeCustom(data.customGames, data.customEvents); + + if (restorePrefs !== undefined) { + if (data.prefs !== null) { + restorePrefs(data.prefs); + } else { + alert("Progress was imported, but this file did not include settings."); + } + } } catch { alert("That file couldn't be read. Export a fresh copy and try again."); } } + diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 1bdb438..8605bd8 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -75,6 +75,8 @@ export function Controls({ ignoredCount, onExport, onImport, + onExportAll, + onImportAll, own, }: { games: LaneId[]; @@ -84,6 +86,8 @@ export function Controls({ ignoredCount: number; onExport: () => void; onImport: (file: File) => void; + onExportAll: () => void; + onImportAll: (file: File) => void; /** Everything the reader entered themselves, and the ways to change it. */ own: React.ComponentProps; }) { @@ -262,12 +266,13 @@ export function Controls({

What you've finished, and every daily you've ticked off, are saved in this browser only — there is no account. Anything you added yourself is - in there too. Move it all to another device with a file. + in there too.

-
+
+
+

+ Use Export and{" "} + Import for progress only. Use{" "} + Export all and{" "} + Import all when moving hosts to carry + your active games, custom order, region, and appearance settings along. +

diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 6afc1fc..4ab8035 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -49,6 +49,8 @@ function render(prefs: Prefs, ignoredCount = 0): string { ignoredCount={ignoredCount} onExport={() => {}} onImport={() => {}} + onExportAll={() => {}} + onImportAll={() => {}} own={{ games: {}, events: {}, @@ -287,3 +289,15 @@ describe("the chores checkbox reports its own pref", () => { expect(off).toBe(on - 1); }); }); + +describe("Controls: backup and export actions", () => { + test("renders all four export and import buttons", () => { + const markup = render(PREFS); + expect(markup).toContain("Export"); + expect(markup).toContain("Import"); + expect(markup).toContain("Import all