feat(progress): keep a check-in goal on the reader's progress record

Goals ride the existing record, so export and the later-edit-wins import
merge carry them with no new key. Setting one clears a contradicting
"not daily"; saying "not daily" clears the goal, so no conflict hides
in storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-13 13:09:05 +02:00
co-authored by Claude Opus 5.5
parent 9e826f22e4
commit 34c7dce5ca
2 changed files with 99 additions and 16 deletions
+52 -15
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import type { Effort } from "../../shared/effort.ts";
import type { Goal } from "../../shared/goal.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
import type { Marks } from "./useMarkSet.ts";
@@ -17,6 +18,13 @@ export interface Progress {
daily?: boolean | undefined;
/** Anything the reader wants to remember about it. */
note?: string | undefined;
/**
* A check-in goal (shared/goal.ts), stored as the reader set it and read
* through `readGoal`. Typed `unknown` on purpose: this store is never
* validated on load, so a goal of a newer shape than this build understands
* is kept rather than stripped on the next write.
*/
goal?: unknown;
at: string;
}
@@ -58,10 +66,41 @@ function isEmpty(p: Progress): boolean {
p.status === undefined &&
p.effort === undefined &&
p.daily === undefined &&
p.goal === undefined &&
(p.note ?? "") === ""
);
}
/** One edit to one record, stamped `at`. Pure, so tests reach it directly. */
export function applyPatch(
prev: ProgressMap,
id: string,
next: Partial<Progress>,
at: string,
): ProgressMap {
const merged: Progress = { ...prev[id], ...next, at };
// An entry with nothing recorded is not worth keeping; drop it so the
// store stays a set of things the reader actually said something about.
if (isEmpty(merged)) {
const { [id]: _removed, ...rest } = prev;
return rest;
}
return { ...prev, [id]: merged };
}
/**
* Setting a goal makes the event a daily; a stored `daily: false` would
* contradict it, so that alone is cleared. Any other `daily` answer stays, so
* removing the goal later restores exactly what was there before.
*/
export function goalPatch(
current: Progress | undefined,
goal: Goal | undefined,
): Partial<Progress> {
if (goal === undefined) return { goal: undefined };
return current?.daily === false ? { goal, daily: undefined } : { goal };
}
/**
* Union merge on import, keeping whichever copy was touched last. Never removes.
*
@@ -114,20 +153,7 @@ export function useProgress() {
}, [progress]);
const patch = useCallback((id: string, next: Partial<Progress>) => {
setProgress((prev) => {
const merged: Progress = {
...prev[id],
...next,
at: new Date().toISOString(),
};
// An entry with nothing recorded is not worth keeping; drop it so the
// store stays a set of things the reader actually said something about.
if (isEmpty(merged)) {
const { [id]: _removed, ...rest } = prev;
return rest;
}
return { ...prev, [id]: merged };
});
setProgress((prev) => applyPatch(prev, id, next, new Date().toISOString()));
}, []);
const setStatus = useCallback(
@@ -141,11 +167,21 @@ export function useProgress() {
// the press after "done" cleared the status instead of undoing it. Status is
// set outright now, by a control per state.
// "This isn't a daily event" must not leave a goal hidden in storage that
// contradicts it, so any daily answer clears the goal in the same write. The
// other direction — "it repeats daily" — only ever runs on an event with no
// goal, because a goal already makes it daily.
const setDaily = useCallback(
(id: string, daily: boolean | undefined) => patch(id, { daily }),
(id: string, daily: boolean | undefined) => patch(id, { daily, goal: undefined }),
[patch],
);
const setGoal = useCallback((id: string, goal: Goal | undefined) => {
setProgress((prev) =>
applyPatch(prev, id, goalPatch(prev[id], goal), new Date().toISOString()),
);
}, []);
const setEffort = useCallback(
(id: string, effort: Effort | undefined) => patch(id, { effort }),
[patch],
@@ -165,6 +201,7 @@ export function useProgress() {
patch,
setStatus,
setDaily,
setGoal,
setEffort,
setNote,
merge,
+47 -1
View File
@@ -1,7 +1,7 @@
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 { applyPatch, goalPatch, mergeProgress } from "../src/client/state/useProgress.ts";
import type { ProgressMap } from "../src/client/state/useProgress.ts";
/**
@@ -175,3 +175,49 @@ describe("parseImportData", () => {
});
describe("goals on progress", () => {
const goal = { period: "week", measure: "days", required: 5, weekStartsOn: 1 } as const;
const T = "2026-10-13T12:00:00.000Z";
test("a goal-only record is kept", () => {
expect(applyPatch({}, "e", { goal }, T).e).toEqual({ goal, at: T });
});
test("clearing the only field drops the record", () => {
expect(applyPatch({ e: { goal, at: T } }, "e", { goal: undefined }, T).e).toBeUndefined();
});
test("setting a goal clears a stored 'not daily', and nothing else", () => {
expect(goalPatch({ daily: false, at: T }, goal)).toStrictEqual({ goal, daily: undefined });
expect(goalPatch({ daily: true, at: T }, goal)).toStrictEqual({ goal });
expect(goalPatch(undefined, undefined)).toStrictEqual({ goal: undefined });
});
test("an unreadable stored goal is left exactly as it was", () => {
// A newer shape, as an older cached bundle would meet it.
const future = { period: "week", measure: "amount", target: 2100, weekStartsOn: 1 };
const next = applyPatch({ e: { goal: future, at: T } }, "e", { note: "hi" }, T);
expect(next.e?.goal).toEqual(future);
});
test("goals survive export and import, and the later record wins", () => {
const device: ProgressMap = { e: { status: "doing", at: at("10") } };
const file = parseImportData(
JSON.parse(
JSON.stringify(
buildExportData({ e: { goal, at: at("11") } }, {}, {}, { games: {}, events: {} }),
),
),
);
expect(mergeProgress(device, file!.progress!).e).toEqual({ goal, at: at("11") });
});
test("an export from before goals imports unchanged", () => {
const old = {
format: "gacha-tracker-export",
version: 1,
progress: { e: { status: "done", at: at("10") } },
};
expect(parseImportData(old)!.progress).toEqual({ e: { status: "done", at: at("10") } });
});
});