feat(ui): add "Export all" and "Import all" buttons for moving hosts
CI / Typecheck, test, feed (push) Successful in 13s
CI / Build site (push) Successful in 8s
CI / Container image (push) Successful in 31s

This commit is contained in:
Lucas Winther
2026-09-17 10:20:07 +02:00
parent b36047d218
commit b73c253de2
4 changed files with 111 additions and 82 deletions
+8
View File
@@ -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: [...] }`.
+54 -80
View File
@@ -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<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
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<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
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<string, { at: string }>) => 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<string, { at: string }>)
: 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.");
}
}
+35 -2
View File
@@ -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<typeof YourOwn>;
}) {
@@ -262,12 +266,13 @@ export function Controls({
<p className="max-w-md text-xs leading-relaxed text-faint">
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.
</p>
<div className="mt-3 flex gap-2">
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={onExport}
title="Export progress, dailies, ignored events, and custom entries"
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Export
@@ -285,7 +290,35 @@ export function Controls({
}}
/>
</label>
<button
type="button"
onClick={onExportAll}
title="Export everything including settings (active games, game order, region, theme) for moving hosts"
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Export all
</button>
<label className="cursor-pointer rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink">
Import all
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onImportAll(file);
e.target.value = "";
}}
/>
</label>
</div>
<p className="mt-2.5 max-w-md text-xs leading-relaxed text-faint">
Use <span className="font-medium text-muted">Export</span> and{" "}
<span className="font-medium text-muted">Import</span> for progress only. Use{" "}
<span className="font-medium text-muted">Export all</span> and{" "}
<span className="font-medium text-muted">Import all</span> when moving hosts to carry
your active games, custom order, region, and appearance settings along.
</p>
</Group>
</div>
</section>
+14
View File
@@ -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</button>");
expect(markup).toContain("Import<input");
expect(markup).toContain("Export all</button>");
expect(markup).toContain("Import all<input");
expect(markup).toContain("when moving hosts");
});
});