feat(state): support full export and preferences restoration
This commit is contained in:
1 parent
0d1ccff6b2
commit
b36047d218
4 files changed
+402
-8
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user