feat(export): carry tasks and amounts in the backup

They exist on this device and nowhere else, so an export without them is
a lossy backup. Both are additive keys, as custom games were: older files
lack them and still import. Tasks travel as stored, including any this
build cannot read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 13:34:56 +02:00
co-authored by Claude Opus 5.5
parent c0a0b94054
commit e97e8b002f
3 changed files with 66 additions and 5 deletions
+21 -4
View File
@@ -17,6 +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 { useTasks } from "./state/useTasks.ts";
import { useAmounts } from "./state/useAmounts.ts";
import { adoptNewLanes, usePrefs, type Prefs } from "./state/usePrefs.ts";
import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts";
import { snapDayWidth } from "./state/zoom.ts";
@@ -39,8 +41,6 @@ import { metaOnTheme, useTheme } from "./state/theme.ts";
import {
displayEventFor,
recordFor,
type CustomEvents,
type CustomGames,
type LaneId,
} from "../shared/custom.ts";
import { metaFor } from "../shared/games.ts";
@@ -113,6 +113,12 @@ export function App() {
const ignored = useMarkSet(KEYS.ignored);
const prog = useProgress();
const daily = useDailyLog();
const tasks = useTasks();
const amounts = useAmounts();
const mergeExtra = (incomingTasks: unknown, incomingAmounts: unknown) => {
tasks.merge(incomingTasks);
amounts.merge(incomingAmounts);
};
const custom = useCustom(now);
// Colour only: which ground the page is drawn on, written to the document by
// the hook. Nothing else in the app asks what it is — the tokens in
@@ -687,6 +693,8 @@ export function App() {
exportProgress(prog.progress, daily.logs, ignored.marks, {
games: custom.games,
events: custom.events,
tasks: tasks.raw,
amounts: amounts.amounts,
})
}
onImport={(file) =>
@@ -696,12 +704,15 @@ export function App() {
daily.merge,
ignored.merge,
custom.merge,
mergeExtra,
)
}
onExportAll={() =>
exportAll(prog.progress, daily.logs, ignored.marks, prefs, {
games: custom.games,
events: custom.events,
tasks: tasks.raw,
amounts: amounts.amounts,
})
}
onImportAll={(file) =>
@@ -711,6 +722,7 @@ export function App() {
daily.merge,
ignored.merge,
custom.merge,
mergeExtra,
restorePrefs,
)
}
@@ -942,7 +954,7 @@ function exportProgress(
progress: Record<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
own: { games: CustomGames; events: CustomEvents },
own: Own,
) {
const data = buildExportData(progress, daily, ignored, own);
triggerDownload(
@@ -956,7 +968,7 @@ function exportAll(
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
prefs: Prefs,
own: { games: CustomGames; events: CustomEvents },
own: Own,
) {
const data = buildExportData(progress, daily, ignored, own, prefs);
triggerDownload(
@@ -965,12 +977,16 @@ function exportAll(
);
}
/** Everything reader-authored that travels in an export beside progress. */
type Own = Parameters<typeof buildExportData>[3];
async function importProgress(
file: File,
mergeProgress: (c: Record<string, { at: string }>) => void,
mergeDaily: (c: DailyLogMap) => void,
mergeIgnored: (c: Record<string, { at: string }>) => void,
mergeCustom: (games: unknown, events: unknown) => void,
mergeExtra: (tasks: unknown, amounts: unknown) => void,
restorePrefs?: ((prefs: unknown) => void) | undefined,
) {
try {
@@ -984,6 +1000,7 @@ async function importProgress(
if (data.daily !== null) mergeDaily(data.daily);
if (data.ignored !== null) mergeIgnored(data.ignored);
mergeCustom(data.customGames, data.customEvents);
mergeExtra(data.tasks, data.amounts);
if (restorePrefs !== undefined) {
if (data.prefs !== null) {
+21 -1
View File
@@ -12,6 +12,10 @@ export interface ExportData {
ignored: Record<string, { at: string }>;
customGames: CustomGames;
customEvents: CustomEvents;
/** Weekly and monthly tasks, as stored — readable to this build or not. */
tasks?: Record<string, unknown> | undefined;
/** Numbers entered against targets, per item per period. */
amounts?: Record<string, Record<string, unknown>> | undefined;
prefs?: Prefs | undefined;
}
@@ -21,6 +25,9 @@ export interface ParsedImport {
ignored: Record<string, { at: string }> | null;
customGames: unknown;
customEvents: unknown;
/** Validated by the task and amount stores' own merges, not here. */
tasks: unknown;
amounts: unknown;
prefs: unknown | null;
}
@@ -33,7 +40,12 @@ export function buildExportData(
progress: Record<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
own: { games: CustomGames; events: CustomEvents },
own: {
games: CustomGames;
events: CustomEvents;
tasks?: Record<string, unknown> | undefined;
amounts?: Record<string, Record<string, unknown>> | undefined;
},
prefs?: Prefs | undefined,
): ExportData {
return {
@@ -45,6 +57,10 @@ export function buildExportData(
ignored,
customGames: own.games,
customEvents: own.events,
// Additive keys, like customGames was: a file without them is simply from
// a device that had none.
...(own.tasks !== undefined ? { tasks: own.tasks } : {}),
...(own.amounts !== undefined ? { amounts: own.amounts } : {}),
...(prefs !== undefined ? { prefs } : {}),
};
}
@@ -63,6 +79,8 @@ export function parseImportData(parsed: unknown): ParsedImport | null {
ignored?: unknown;
customGames?: unknown;
customEvents?: unknown;
tasks?: unknown;
amounts?: unknown;
prefs?: unknown;
};
if (data.format !== "gacha-tracker-export") return null;
@@ -98,6 +116,8 @@ export function parseImportData(parsed: unknown): ParsedImport | null {
ignored,
customGames: data.customGames,
customEvents: data.customEvents,
tasks: data.tasks,
amounts: data.amounts,
prefs,
};
}
+24
View File
@@ -234,3 +234,27 @@ describe("a goal this build cannot read is never cleared by accident", () => {
expect(dailyPatch(false, true)).toStrictEqual({ daily: false, goal: undefined });
});
});
describe("tasks and amounts in the export", () => {
const T = "2026-10-13T12:00:00.000Z";
const tasks = {
"mytask:aaaaaa1111": { id: "mytask:aaaaaa1111", game: "zzz", title: "Activity" },
// Unreadable to this build, and carried anyway: the export is a backup.
"mytask:cccccc3333": { id: "mytask:cccccc3333", measure: { kind: "streak" } },
};
const amounts = { "mytask:aaaaaa1111": { "2026-10-12": { value: 1900, at: T } } };
test("a round trip carries both, readable or not", () => {
const data = buildExportData({}, {}, {}, { games: {}, events: {}, tasks, amounts });
const parsed = parseImportData(JSON.parse(JSON.stringify(data)))!;
expect(parsed.tasks).toEqual(tasks);
expect(parsed.amounts).toEqual(amounts);
});
test("an export from before tasks has neither, and is still valid", () => {
const parsed = parseImportData({ format: "gacha-tracker-export", version: 1, progress: {} })!;
expect(parsed).not.toBeNull();
expect(parsed.tasks).toBeUndefined();
expect(parsed.amounts).toBeUndefined();
});
});