diff --git a/src/client/state/export.ts b/src/client/state/export.ts new file mode 100644 index 0000000..55e6058 --- /dev/null +++ b/src/client/state/export.ts @@ -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; + daily: DailyLogMap; + ignored: Record; + customGames: CustomGames; + customEvents: CustomEvents; + prefs?: Prefs | undefined; +} + +export interface ParsedImport { + progress: ProgressMap | null; + daily: DailyLogMap | null; + ignored: Record | 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, + daily: DailyLogMap, + ignored: Record, + 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) + : null; + const asMarks = (v: unknown) => + typeof v === "object" && v !== null + ? (v as Record) + : null; + + let progress: ProgressMap | null = asRecord(data.progress) as ProgressMap | null; + const legacy = asRecord(data.completions) as Record | 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); +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 6a1aa5b..5eec284 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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 & { 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(() => ({ ...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 }; } + diff --git a/test/prefs.test.ts b/test/prefs.test.ts index 31c1305..ddfc035 100644 --- a/test/prefs.test.ts +++ b/test/prefs.test.ts @@ -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); + }); +}); diff --git a/test/progress.test.ts b/test/progress.test.ts index 103930d..19f371b 100644 --- a/test/progress.test.ts +++ b/test/progress.test.ts @@ -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(); + }); +}); + +