feat(state): support full export and preferences restoration

This commit is contained in:
Lucas Winther committed 2026-09-17 10:19:59 +02:00
1 parent 0d1ccff6b2
commit b36047d218
4 files changed
+402 -8

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import type { CustomEvents, CustomGames } from "../../shared/custom.ts";
import type { DailyLogMap } from "./useDailyLog.ts";
import type { Prefs } from "./usePrefs.ts";
import type { ProgressMap } from "./useProgress.ts";
export interface ExportData {
format: "gacha-tracker-export";
version: 1;
exportedAt: string;
progress: Record<string, unknown>;
daily: DailyLogMap;
ignored: Record<string, { at: string }>;
customGames: CustomGames;
customEvents: CustomEvents;
prefs?: Prefs | undefined;
}
export interface ParsedImport {
progress: ProgressMap | null;
daily: DailyLogMap | null;
ignored: Record<string, { at: string }> | null;
customGames: unknown;
customEvents: unknown;
prefs: unknown | null;
}
/**
* Builds the serializable JSON export object.
* When `prefs` is provided, includes preferences for moving hosts ("Export all").
* When omitted, exports progress and custom data only ("Export").
*/
export function buildExportData(
progress: Record<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
own: { games: CustomGames; events: CustomEvents },
prefs?: Prefs | undefined,
): ExportData {
return {
format: "gacha-tracker-export",
version: 1,
exportedAt: new Date().toISOString(),
progress,
daily,
ignored,
customGames: own.games,
customEvents: own.events,
...(prefs !== undefined ? { prefs } : {}),
};
}
/**
* Validates and parses raw JSON from an import file.
* Returns null if the file is not a valid Event Clock export.
*/
export function parseImportData(parsed: unknown): ParsedImport | null {
if (typeof parsed !== "object" || parsed === null) return null;
const data = parsed as {
format?: string;
progress?: unknown;
completions?: unknown;
daily?: unknown;
ignored?: unknown;
customGames?: unknown;
customEvents?: unknown;
prefs?: unknown;
};
if (data.format !== "gacha-tracker-export") return null;
const asRecord = (v: unknown) =>
typeof v === "object" && v !== null
? (v as Record<string, unknown>)
: null;
const asMarks = (v: unknown) =>
typeof v === "object" && v !== null
? (v as Record<string, { at: string }>)
: null;
let progress: ProgressMap | null = asRecord(data.progress) as ProgressMap | null;
const legacy = asRecord(data.completions) as Record<string, { at: string }> | null;
if (progress === null && legacy !== null) {
progress = Object.fromEntries(
Object.entries(legacy).map(([id, m]) => [id, { ...m, status: "done" }]),
);
}
const daily =
typeof data.daily === "object" && data.daily !== null
? (data.daily as DailyLogMap)
: null;
const ignored = asMarks(data.ignored);
const prefs =
typeof data.prefs === "object" && data.prefs !== null ? data.prefs : null;
return {
progress,
daily,
ignored,
customGames: data.customGames,
customEvents: data.customEvents,
prefs,
};
}
/**
* Initiates a browser download of a JSON object as a file.
*/
export function triggerDownload(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
+101 -7
View File
@@ -6,7 +6,7 @@ import type { SortMode } from "./sort.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
import type { TimelineGroup } from "./lanes.ts";
import { DEFAULT_THEME_CHOICE, type ThemeChoice } from "./theme.ts";
import { DEFAULT_DAY_WIDTH } from "./zoom.ts";
import { DEFAULT_DAY_WIDTH, snapDayWidth } from "./zoom.ts";
/**
* Which of the two views the reader is looking at.
@@ -62,11 +62,11 @@ export interface Prefs {
* `JSON.stringify` drops it on the way to storage.
*
* Not a key space and not a migration: one more field in the single `prefs`
* blob, like `timelineGroup`. It rides the export for free, though note that
* `importProgress` restores progress, dailies, ignores and the reader's own
* games — never prefs — so an imported file does not bring an order back.
* That asymmetry predates this field and applies to region, theme and view
* alike.
* blob, like `timelineGroup`. It rides the export for free. Standard
* progress import restores progress, dailies, ignores and the reader's own
* games — leaving local prefs intact — while "Import all" restores preferences
* alongside them, bringing game order, region, theme and active games back
* when migrating hosts.
*/
gameOrder?: LaneId[] | undefined;
/**
@@ -269,6 +269,95 @@ export function adoptRenamed(
return { ...rest, showUpcoming: timelineUpcoming };
}
/**
* Takes incoming preferences (e.g. from an imported backup file) and restores a
* clean, type-safe Prefs object, falling back to defaults for any missing or
* invalid values.
*/
export function restorePrefsValue(incoming: unknown): Prefs {
const base = defaults();
if (typeof incoming !== "object" || incoming === null) {
return base;
}
const raw = adoptRenamed(incoming as Partial<Prefs> & { timelineUpcoming?: boolean });
const result: Prefs = {
...base,
...raw,
};
// Region
if (result.region !== "america" && result.region !== "europe" && result.region !== "asia") {
result.region = base.region;
}
// Games arrays
if (!Array.isArray(result.hiddenGames)) {
result.hiddenGames = base.hiddenGames;
} else {
result.hiddenGames = result.hiddenGames.filter((g): g is LaneId => typeof g === "string");
}
if (result.knownGames !== undefined) {
if (!Array.isArray(result.knownGames)) {
delete result.knownGames;
} else {
result.knownGames = result.knownGames.filter((g): g is LaneId => typeof g === "string");
}
}
if (result.gameOrder !== undefined) {
if (!Array.isArray(result.gameOrder)) {
result.gameOrder = undefined;
} else {
result.gameOrder = result.gameOrder.filter((g): g is LaneId => typeof g === "string");
}
}
// focusGame
if (result.focusGame !== null && typeof result.focusGame !== "string") {
result.focusGame = null;
}
// Sort
if (result.sort !== "ending" && result.sort !== "doing") {
result.sort = base.sort;
}
// View
if (result.view !== "soon" && result.view !== "timeline") {
result.view = base.view;
}
// Zoom
if (typeof result.timelineDayWidth === "number") {
result.timelineDayWidth = snapDayWidth(result.timelineDayWidth);
} else {
result.timelineDayWidth = base.timelineDayWidth;
}
// Timeline grouping
if (result.timelineGroup !== "game" && result.timelineGroup !== "ending") {
result.timelineGroup = base.timelineGroup;
}
// Theme
if (result.theme !== "dark" && result.theme !== "light" && result.theme !== "system") {
result.theme = base.theme;
}
// Booleans
result.showUpcoming = typeof result.showUpcoming === "boolean" ? result.showUpcoming : base.showUpcoming;
result.timelineSplitUpcoming = typeof result.timelineSplitUpcoming === "boolean" ? result.timelineSplitUpcoming : base.timelineSplitUpcoming;
result.detectDaily = typeof result.detectDaily === "boolean" ? result.detectDaily : base.detectDaily;
result.showChores = typeof result.showChores === "boolean" ? result.showChores : base.showChores;
result.showCompleted = typeof result.showCompleted === "boolean" ? result.showCompleted : base.showCompleted;
result.showIgnored = typeof result.showIgnored === "boolean" ? result.showIgnored : base.showIgnored;
result.regionConfirmed = typeof result.regionConfirmed === "boolean" ? result.regionConfirmed : base.regionConfirmed;
result.onboarded = typeof result.onboarded === "boolean" ? result.onboarded : base.onboarded;
return result;
}
export function usePrefs() {
const [prefs, setPrefs] = useState<Prefs>(() => ({
...defaults(),
@@ -283,6 +372,10 @@ export function usePrefs() {
setPrefs((prev) => ({ ...prev, ...patch }));
}, []);
const restorePrefs = useCallback((incoming: unknown) => {
setPrefs(restorePrefsValue(incoming));
}, []);
const toggleGame = useCallback((game: LaneId) => {
setPrefs((prev) => ({
...prev,
@@ -292,5 +385,6 @@ export function usePrefs() {
}));
}, []);
return { prefs, update, toggleGame };
return { prefs, update, toggleGame, restorePrefs };
}
+75 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { adoptNewLanes, adoptRenamed, defaults } from "../src/client/state/usePrefs.ts";
import { adoptNewLanes, adoptRenamed, defaults, restorePrefsValue } from "../src/client/state/usePrefs.ts";
import type { LaneId } from "../src/shared/custom.ts";
/**
@@ -123,3 +123,77 @@ describe("defaults that a reader would notice losing", () => {
expect({ ...defaults(), ...stored }.showChores).toBe(true);
});
});
describe("restorePrefsValue", () => {
test("returns defaults when passed null or non-object", () => {
expect(restorePrefsValue(null)).toEqual(defaults());
expect(restorePrefsValue(undefined)).toEqual(defaults());
expect(restorePrefsValue("invalid")).toEqual(defaults());
});
test("restores full valid preferences from an export", () => {
const exported = {
region: "asia" as const,
hiddenGames: ["genshin", "hsr"],
knownGames: ["genshin", "hsr", "zzz"],
gameOrder: ["zzz", "genshin"],
focusGame: "zzz",
sort: "doing" as const,
view: "timeline" as const,
timelineDayWidth: 48,
timelineGroup: "ending" as const,
showUpcoming: true,
timelineSplitUpcoming: false,
detectDaily: true,
showChores: false,
showCompleted: false,
showIgnored: true,
theme: "light" as const,
regionConfirmed: true,
onboarded: true,
};
expect(restorePrefsValue(exported)).toEqual(exported);
});
test("falls back to defaults for missing fields from older exports", () => {
const older = {
region: "europe" as const,
hiddenGames: ["zzz"],
theme: "dark" as const,
};
const restored = restorePrefsValue(older);
expect(restored.region).toBe("europe");
expect(restored.hiddenGames).toEqual(["zzz"]);
expect(restored.theme).toBe("dark");
// missing fields use defaults
expect(restored.showChores).toBe(defaults().showChores);
expect(restored.timelineGroup).toBe(defaults().timelineGroup);
expect(restored.sort).toBe(defaults().sort);
});
test("adopts renamed fields like timelineUpcoming to showUpcoming", () => {
const legacy = {
timelineUpcoming: true,
};
const restored = restorePrefsValue(legacy);
expect(restored.showUpcoming).toBe(true);
});
test("sanitizes corrupt or invalid fields", () => {
const corrupt = {
region: "mars",
hiddenGames: "not-an-array",
gameOrder: 123,
timelineDayWidth: -99,
theme: "neon",
view: "grid",
};
const restored = restorePrefsValue(corrupt);
expect(restored.region).toBe(defaults().region);
expect(restored.hiddenGames).toEqual([]);
expect(restored.gameOrder).toBeUndefined();
expect(restored.timelineDayWidth).toBe(defaults().timelineDayWidth);
expect(restored.theme).toBe(defaults().theme);
expect(restored.view).toBe(defaults().view);
});
});
+105
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { buildExportData, parseImportData } from "../src/client/state/export.ts";
import { restorePrefsValue } from "../src/client/state/usePrefs.ts";
import { mergeProgress } from "../src/client/state/useProgress.ts";
import type { ProgressMap } from "../src/client/state/useProgress.ts";
@@ -70,3 +72,106 @@ describe("mergeProgress", () => {
expect(Object.keys(mergeProgress(device, {})).sort()).toEqual(["a", "b"]);
});
});
describe("buildExportData", () => {
const dummyOwn = { games: {}, events: {} };
test("progress export omits prefs", () => {
const data = buildExportData({ a: { status: "done" } }, {}, {}, dummyOwn);
expect(data.format).toBe("gacha-tracker-export");
expect(data.version).toBe(1);
expect(data.prefs).toBeUndefined();
expect(data.progress).toEqual({ a: { status: "done" } });
});
test("export all includes prefs", () => {
const prefs = { region: "asia" } as any;
const data = buildExportData({ a: { status: "done" } }, {}, {}, dummyOwn, prefs);
expect(data.format).toBe("gacha-tracker-export");
expect(data.version).toBe(1);
expect(data.prefs).toEqual(prefs);
});
});
describe("parseImportData", () => {
test("returns null for non-objects or invalid format", () => {
expect(parseImportData(null)).toBeNull();
expect(parseImportData("string")).toBeNull();
expect(parseImportData({ format: "unknown" })).toBeNull();
});
test("parses export with progress, daily, ignored, and prefs", () => {
const file = {
format: "gacha-tracker-export",
version: 1,
progress: { "event:1": { status: "done" as const, at: "2026-08-10T12:00:00.000Z" } },
daily: { "dailies:genshin": { days: ["2026-08-10"], at: "2026-08-10T12:00:00.000Z" } },
ignored: { "event:2": { at: "2026-08-10T12:00:00.000Z" } },
customGames: { "mygame:test": { id: "mygame:test" } },
customEvents: { "myevent:test": { id: "myevent:test" } },
prefs: { region: "europe", hiddenGames: ["zzz"] },
};
const parsed = parseImportData(file);
expect(parsed).not.toBeNull();
expect(parsed?.progress).toEqual(file.progress);
expect(parsed?.daily).toEqual(file.daily as any);
expect(parsed?.ignored).toEqual(file.ignored);
expect(parsed?.customGames).toEqual(file.customGames);
expect(parsed?.customEvents).toEqual(file.customEvents);
expect(parsed?.prefs).toEqual(file.prefs);
});
test("maps legacy completions to progress with status done", () => {
const file = {
format: "gacha-tracker-export",
version: 1,
completions: { "legacy:1": { at: "2026-08-10T12:00:00.000Z" } },
};
const parsed = parseImportData(file);
expect(parsed?.progress).toEqual({
"legacy:1": { at: "2026-08-10T12:00:00.000Z", status: "done" as const },
});
expect(parsed?.prefs).toBeNull();
});
test("full migration round-trip preserves active games, order, and region", () => {
const originalPrefs = {
region: "asia" as const,
hiddenGames: ["genshin"],
gameOrder: ["zzz", "hsr", "genshin"],
theme: "light" as const,
sort: "doing" as const,
};
const progress = {
"event:1": { status: "doing" as const, at: "2026-08-10T12:00:00.000Z" },
};
const own = {
games: { "mygame:custom": { id: "mygame:custom", name: "Custom", hue: "#ff0000", at: "..." } },
events: {},
};
// 1. Export all creates payload with prefs
const exportedAll = buildExportData(progress, {}, {}, own, originalPrefs as any);
const jsonString = JSON.stringify(exportedAll);
// 2. Import parses the file
const imported = parseImportData(JSON.parse(jsonString));
expect(imported).not.toBeNull();
expect(imported?.prefs).not.toBeNull();
// 3. restorePrefsValue restores the exact preferences
const restored = restorePrefsValue(imported!.prefs);
expect(restored.region).toBe("asia");
expect(restored.hiddenGames).toEqual(["genshin"]);
expect(restored.gameOrder).toEqual(["zzz", "hsr", "genshin"]);
expect(restored.theme).toBe("light");
expect(restored.sort).toBe("doing");
// 4. In contrast, standard progress export has null prefs on import
const exportedProgressOnly = buildExportData(progress, {}, {}, own);
const importedProgressOnly = parseImportData(JSON.parse(JSON.stringify(exportedProgressOnly)));
expect(importedProgressOnly?.prefs).toBeNull();
});
});