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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9c2d63b73b
commit
c0a0b94054
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, Record<string, unknown>>, 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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user