diff --git a/src/client/state/useProgress.ts b/src/client/state/useProgress.ts index b08403a..d36f51d 100644 --- a/src/client/state/useProgress.ts +++ b/src/client/state/useProgress.ts @@ -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, + 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 { + 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) => { - 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, diff --git a/test/progress.test.ts b/test/progress.test.ts index 19f371b..4c88e82 100644 --- a/test/progress.test.ts +++ b/test/progress.test.ts @@ -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") } }); + }); +});