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:
co-authored by
Claude Opus 5.5
parent
9e826f22e4
commit
34c7dce5ca
@@ -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
@@ -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") } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user