diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts index 4839c8d..7b4a04a 100644 --- a/src/client/state/storage.ts +++ b/src/client/state/storage.ts @@ -35,6 +35,13 @@ export const KEYS = { */ customGames: `${NS}:customGames`, customEvents: `${NS}:customEvents`, + /** + * Weekly and monthly tasks the reader set up (task.ts), and the numbers they + * entered against targets, one per item per period. Two keys because a task + * is a definition and an amount is a log; the log outlives a deleted task. + */ + tasks: `${NS}:tasks`, + amounts: `${NS}:amounts`, } as const; /** diff --git a/src/client/state/useAmounts.ts b/src/client/state/useAmounts.ts new file mode 100644 index 0000000..af7be50 --- /dev/null +++ b/src/client/state/useAmounts.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AmountLog } from "../../shared/goal.ts"; +import { KEYS, readJson, writeJson } from "./storage.ts"; + +/** Every item's numbers: `{ [itemKey]: { [periodStart]: { value, at } } }`. */ +export type AmountLogMap = Record; + +/** + * The store as it sits on disk. Entries are checked where they are read, not + * when loaded: filtering on load would make the next write delete whatever did + * not parse, and the likeliest thing that does not parse is a newer version's. + */ +export type StoredAmounts = Record>; + +/** One item's readable entries; anything malformed is left in storage and skipped here. */ +export function readableAmounts(log: Record | undefined): AmountLog { + const out: AmountLog = {}; + for (const [period, entry] of Object.entries(log ?? {})) { + if (isEntry(entry)) out[period] = { value: entry.value, at: entry.at }; + } + return out; +} + +const isEntry = (v: unknown): v is { value: number; at: string } => + typeof v === "object" && + v !== null && + typeof (v as { value: unknown }).value === "number" && + Number.isFinite((v as { value: number }).value) && + typeof (v as { at: unknown }).at === "string"; + +/** + * Import: merge per item **and** per period, the later `at` winning, nothing + * removed. + * + * Per period because a week's number is its own fact: restoring last month's + * backup must not roll this week's 1,900 back to an earlier 800, and must not + * lose a week the device never saw. A malformed entry is skipped — a file is + * untrusted input — rather than allowed to take the rest with it. + */ +export function mergeAmounts(current: StoredAmounts, incoming: unknown): StoredAmounts { + if (typeof incoming !== "object" || incoming === null) return current; + const next: StoredAmounts = { ...current }; + for (const [key, log] of Object.entries(incoming as Record)) { + if (typeof log !== "object" || log === null) continue; + for (const [period, entry] of Object.entries(log as Record)) { + if (!isEntry(entry)) continue; + const mine = next[key]?.[period]; + if (isEntry(mine) && mine.at >= entry.at) continue; + const log = next[key]; + const kept = typeof log === "object" && log !== null ? log : {}; + next[key] = { ...kept, [period]: { value: entry.value, at: entry.at } }; + } + } + return next; +} + +/** + * Numbers the reader entered against a target, one per item per period. + * + * Kept apart from the day log because it is a different kind of fact — a + * running total rather than a set of days — and from `progress` because it + * grows a value per week. Nothing here prunes an old period: last month's + * number is the reader's record and costs a few bytes. + */ +export function useAmounts() { + const [amounts, setAmounts] = useState(() => { + const raw = readJson(KEYS.amounts, {}); + return typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? (raw as StoredAmounts) + : {}; + }); + + useEffect(() => { + writeJson(KEYS.amounts, amounts); + }, [amounts]); + + const amountsFor = useCallback( + (key: string): AmountLog => readableAmounts(amounts[key]), + [amounts], + ); + + /** Set one period's number; undefined clears that period alone, at the reader's word. */ + const setAmount = useCallback( + (key: string, periodStart: string, value: number | undefined) => { + setAmounts((prev) => { + const { [periodStart]: _old, ...rest } = prev[key] ?? {}; + const log = + value === undefined ? rest : { ...rest, [periodStart]: { value, at: new Date().toISOString() } }; + if (Object.keys(log).length === 0) { + const { [key]: _gone, ...others } = prev; + return others; + } + return { ...prev, [key]: log }; + }); + }, + [], + ); + + const merge = useCallback((incoming: unknown) => { + setAmounts((prev) => mergeAmounts(prev, incoming)); + }, []); + + return { amounts, amountsFor, setAmount, merge }; +} diff --git a/src/client/state/useTasks.ts b/src/client/state/useTasks.ts new file mode 100644 index 0000000..5339b82 --- /dev/null +++ b/src/client/state/useTasks.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Measure, Task } from "../../shared/task.ts"; +import { mintTaskId, TaskSchema } from "../../shared/task.ts"; +import type { Period } from "../../shared/period.ts"; +import { KEYS, readJson, writeJson } from "./storage.ts"; + +/** What the task form hands over; the store stamps id and times. */ +export interface TaskDraft { + game: string; + title: string; + period: Period; + measure: Measure; +} + +/** The store as it sits on disk, every record kept whether or not this build reads it. */ +export type StoredTasks = Record; + +/** + * The tasks this build can read, ordered by game and then title. + * + * **Display only.** `useCustom` filters on load and its next write deletes what + * did not parse; this store does not, because the likeliest unreadable task is + * one a newer version wrote, and opening an older tab must not erase it. + */ +export function visibleTasks(raw: StoredTasks): Task[] { + const out: Task[] = []; + for (const value of Object.values(raw)) { + const parsed = TaskSchema.safeParse(value); + if (parsed.success) out.push(parsed.data); + } + return out.sort((a, b) => a.game.localeCompare(b.game) || a.title.localeCompare(b.title)); +} + +const updatedAtOf = (v: unknown): string => { + const at = (v as { updatedAt?: unknown } | null)?.updatedAt; + return typeof at === "string" ? at : ""; +}; + +/** + * Import: union by id, the later `updatedAt` winning, nothing removed. + * + * Incoming records are validated — a file is untrusted input — and one that + * does not parse is skipped. Records already on the device are kept as they + * are, readable or not. + */ +export function mergeTasks(current: StoredTasks, incoming: unknown): StoredTasks { + if (typeof incoming !== "object" || incoming === null) return current; + const next: StoredTasks = { ...current }; + for (const value of Object.values(incoming as Record)) { + const parsed = TaskSchema.safeParse(value); + if (!parsed.success) continue; + const id = parsed.data.id; + if (next[id] === undefined || parsed.data.updatedAt > updatedAtOf(next[id])) { + next[id] = parsed.data; + } + } + return next; +} + +/** + * The reader's weekly and monthly tasks (PRD F17). + * + * Deleting one removes the task record and nothing else: its ticks stay in the + * day log and its numbers in the amounts store, like every log in this app. + */ +export function useTasks() { + const [raw, setRaw] = useState(() => { + const stored = readJson(KEYS.tasks, {}); + return typeof stored === "object" && stored !== null && !Array.isArray(stored) + ? (stored as StoredTasks) + : {}; + }); + + useEffect(() => { + writeJson(KEYS.tasks, raw); + }, [raw]); + + const tasks = useMemo(() => visibleTasks(raw), [raw]); + + const addTask = useCallback((draft: TaskDraft): string => { + const now = new Date().toISOString(); + const task = TaskSchema.parse({ ...draft, id: mintTaskId(), at: now, updatedAt: now }); + setRaw((prev) => ({ ...prev, [task.id]: task })); + return task.id; + }, []); + + const editTask = useCallback((id: string, draft: TaskDraft) => { + setRaw((prev) => { + const existing = TaskSchema.safeParse(prev[id]); + if (!existing.success) return prev; + const next = TaskSchema.parse({ + ...existing.data, + ...draft, + updatedAt: new Date().toISOString(), + }); + return { ...prev, [id]: next }; + }); + }, []); + + const removeTask = useCallback((id: string) => { + setRaw((prev) => { + const { [id]: _gone, ...rest } = prev; + return rest; + }); + }, []); + + const merge = useCallback((incoming: unknown) => { + setRaw((prev) => mergeTasks(prev, incoming)); + }, []); + + return { raw, tasks, addTask, editTask, removeTask, merge }; +} diff --git a/test/tasks-store.test.ts b/test/tasks-store.test.ts new file mode 100644 index 0000000..4eb9545 --- /dev/null +++ b/test/tasks-store.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mergeAmounts, readableAmounts } from "../src/client/state/useAmounts.ts"; +import { mergeTasks, visibleTasks } from "../src/client/state/useTasks.ts"; + +/** + * The two stores weekly and monthly tasks add. Both are the only copy of what + * they hold, so the rules are the repo's usual ones: nothing is dropped on + * load, and an import merges without ever removing or rolling back. + */ + +const task = (id: string, title: string, updatedAt: string, game = "zzz") => ({ + id, game, title, + period: { unit: "week", startsOn: 1 }, + measure: { kind: "days", required: 1 }, + at: "2026-10-01T00:00:00.000Z", + updatedAt, +}); +const A = "mytask:aaaaaa1111"; +const B = "mytask:bbbbbb2222"; +// A shape no version of this build understands: a later version's task. +const FUTURE = { ...task("mytask:cccccc3333", "Later", "2026-10-02T00:00:00.000Z"), measure: { kind: "streak", n: 3 } }; + +describe("tasks", () => { + test("an unreadable stored task is hidden, never dropped", () => { + const raw = { [A]: task(A, "Weekly", "2026-10-02T00:00:00.000Z"), [FUTURE.id]: FUTURE }; + expect(visibleTasks(raw).map((t) => t.id)).toEqual([A]); + const merged = mergeTasks(raw, {}); + expect(merged[FUTURE.id]).toEqual(FUTURE); + }); + + test("import keeps both sides and the later edit wins, either direction", () => { + const device = { [A]: task(A, "New name", "2026-10-05T00:00:00.000Z") }; + const file = { + [A]: task(A, "Old name", "2026-10-03T00:00:00.000Z"), + [B]: task(B, "Monthly", "2026-10-03T00:00:00.000Z"), + }; + const merged = mergeTasks(device, file); + expect(Object.keys(merged).sort()).toEqual([A, B]); + expect((merged[A] as { title: string }).title).toBe("New name"); + expect((mergeTasks(file, device)[A] as { title: string }).title).toBe("New name"); + }); + + test("a malformed incoming task is skipped, not let in", () => { + const merged = mergeTasks({}, { [A]: { id: A, title: 7 }, junk: "x" }); + expect(merged).toEqual({}); + expect(mergeTasks({}, "not an object")).toEqual({}); + }); + + test("visible tasks are ordered by game, then title", () => { + const raw = { + [A]: task(A, "b", "2026-10-02T00:00:00.000Z", "zzz"), + [B]: task(B, "a", "2026-10-02T00:00:00.000Z", "endfield"), + }; + expect(visibleTasks(raw).map((t) => t.title)).toEqual(["a", "b"]); + }); +}); + +describe("amounts", () => { + const value = (m: Record>, key: string, period: string) => + readableAmounts(m[key])[period]?.value; + const T1 = "2026-10-14T00:00:00.000Z"; + const T2 = "2026-10-15T00:00:00.000Z"; + + test("an older backup cannot roll back a newer week", () => { + const device = { [A]: { "2026-10-12": { value: 1900, at: T2 } } }; + const file = { [A]: { "2026-10-12": { value: 800, at: T1 } } }; + expect(value(mergeAmounts(device, file), A, "2026-10-12")).toBe(1900); + expect(value(mergeAmounts(file, device), A, "2026-10-12")).toBe(1900); + }); + + test("a period only one side has survives", () => { + const device = { [A]: { "2026-10-12": { value: 1900, at: T2 } } }; + const file = { [A]: { "2026-10-05": { value: 2100, at: T1 } }, [B]: { "2026-10-01": { value: 3, at: T1 } } }; + const merged = mergeAmounts(device, file); + expect(Object.keys(merged[A]!).sort()).toEqual(["2026-10-05", "2026-10-12"]); + expect(value(merged, B, "2026-10-01")).toBe(3); + }); + + test("a malformed stored entry is kept on disk and skipped when read", () => { + const stored = { [A]: { "2026-10-12": { value: "lots", at: T1 }, "2026-10-05": { value: 5, at: T1 } } }; + const merged = mergeAmounts(stored, {}); + expect(merged[A]!["2026-10-12"]).toEqual({ value: "lots", at: T1 }); + expect(readableAmounts(merged[A])).toEqual({ "2026-10-05": { value: 5, at: T1 } }); + }); + + test("non-numbers and bad shapes are skipped", () => { + const merged = mergeAmounts({}, { + [A]: { "2026-10-12": { value: "1900", at: T1 }, "2026-10-05": { value: Number.NaN, at: T1 } }, + [B]: "nope", + }); + expect(merged).toEqual({}); + }); +});