feat(ui): add "Export all" and "Import all" buttons for moving hosts
This commit is contained in:
@@ -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
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user