feat(tasks): store the reader's tasks and the numbers they enter

Both stores keep what is on disk and validate where they read, unlike the
custom-event store. Filtering on load would let the next write delete a
newer version's records. Import merges by id, or by item and period for
amounts, and the later edit wins, so a restored backup cannot roll a
week back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 13:33:59 +02:00
1 parent 9c2d63b73b
commit c0a0b94054
4 files changed
+316

No files matched your search

+7
View File
@@ -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;
/**
+104
View File
@@ -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<string, AmountLog>;
/**
* 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<string, Record<string, unknown>>;
/** One item's readable entries; anything malformed is left in storage and skipped here. */
export function readableAmounts(log: Record<string, unknown> | 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<string, unknown>)) {
if (typeof log !== "object" || log === null) continue;
for (const [period, entry] of Object.entries(log as Record<string, unknown>)) {
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<StoredAmounts>(() => {
const raw = readJson<unknown>(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 };
}
+112
View File
@@ -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<string, unknown>;
/**
* 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<string, unknown>)) {
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<StoredTasks>(() => {
const stored = readJson<unknown>(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 };
}