diff --git a/src/client/components/GoalControl.tsx b/src/client/components/GoalControl.tsx index 4cc16e1..e305176 100644 --- a/src/client/components/GoalControl.tsx +++ b/src/client/components/GoalControl.tsx @@ -25,7 +25,7 @@ export function chooseGoal( ): Goal | undefined | null { if (id === (goal?.period ?? "off")) return null; if (id === "off") return undefined; - const required = goal?.required ?? 5; + const required = goal?.measure === "days" ? goal.required : 5; if (id === "week") { return { period: "week", @@ -103,7 +103,9 @@ export function GoalControl({ {goal !== undefined && (
- + {goal.measure === "days" && ( + + )} {goal.period === "week" ? "days each week, weeks start" : "days across the event"} @@ -139,7 +141,7 @@ function CountField({ maxDays, onChange, }: { - goal: Goal; + goal: Extract; maxDays: number | null; onChange: (goal: Goal) => void; }) { diff --git a/src/shared/goal.ts b/src/shared/goal.ts index c3ef09e..249ff54 100644 --- a/src/shared/goal.ts +++ b/src/shared/goal.ts @@ -4,6 +4,9 @@ import { dailyDays, dayKey } from "./daily.ts"; import { addDays, periodOf, WeekdaySchema } from "./period.ts"; import type { Region } from "./schema.ts"; +/** A whole, positive number; anything a game would plausibly count to. */ +export const AmountTarget = z.number().int().min(1).max(1_000_000_000); + /** * A check-in goal: the event wants N distinct game-days, per week or across its * whole run, rather than every day. @@ -28,9 +31,33 @@ export const GoalSchema = z.union([ measure: z.literal("days"), required: z.number().int().min(1).max(366), }), + // A number against a target — 2,100 activity points a week — entered by the + // reader per period and stored in the amounts store, not the day log. + z.object({ + period: z.literal("week"), + measure: z.literal("amount"), + target: AmountTarget, + weekStartsOn: WeekdaySchema, + }), + z.object({ + period: z.literal("event"), + measure: z.literal("amount"), + target: AmountTarget, + }), ]); export type Goal = z.infer; +/** What a goal asks for, in its own measure: days, or the target number. */ +export function goalRequired(goal: Goal): number { + return goal.measure === "days" ? goal.required : goal.target; +} + +/** + * A reader's numbers for one item, by the start key of the period they were + * entered for. One value per period; a new period starts with none. + */ +export type AmountLog = Record; + /** * A stored goal, or undefined when absent or unreadable. * @@ -45,9 +72,13 @@ export function readGoal(stored: unknown): Goal | undefined { } export interface GoalStatus { - scope: "week" | "event"; + /** Month is a task's period (task.ts); an event's goal is a week or the event. */ + scope: "week" | "month" | "event"; + measure: "days" | "amount"; + /** The period's first game-day: the key an amount is stored under. */ + periodStart: string; required: number; - /** Distinct ticked game-days in scope, never later than today. */ + /** Distinct ticked game-days in scope, never later than today — or, for an amount, the value entered for this period. */ count: number; met: boolean; needed: number; @@ -79,33 +110,56 @@ export function goalStatus(input: { game: LaneId; now: number; logged: readonly string[]; + /** This item's entered numbers, for an amount goal. */ + amounts?: AmountLog | undefined; }): GoalStatus { - const { goal, startsMs, endsMs, region, game, now, logged } = input; + const { goal, startsMs, endsMs, region, game, now, logged, amounts } = input; const today = dayKey(now, region, game); const first = dayKey(startsMs, region, game); const run = dailyDays(startsMs, endsMs, region, game); let scope: string[]; + let periodStart: string; if (goal.period === "week") { // The rule defines this week's boundary even when the event's end is // unknown; the start still clips it, because a day before the event opened // was never claimable. - const week = periodOf({ unit: "week", startsOn: goal.weekStartsOn }, today).days; - scope = run === null ? week.filter((k) => k >= first) : week.filter((k) => run.includes(k)); + const week = periodOf({ unit: "week", startsOn: goal.weekStartsOn }, today); + periodStart = week.start; + scope = + run === null ? week.days.filter((k) => k >= first) : week.days.filter((k) => run.includes(k)); } else { + periodStart = first; scope = run ?? keysFrom(first, today); } + const unknowable = goal.period === "event" && run === null; + if (goal.measure === "amount") { + const count = amounts?.[periodStart]?.value ?? 0; + return { + scope: goal.period, + measure: "amount", + periodStart, + required: goal.target, + count, + met: count >= goal.target, + needed: Math.max(0, goal.target - count), + open: unknowable ? null : scope.filter((k) => k >= today).length, + // How much is left to earn per day is the game's business, not ours; + // a number cannot be short of days the way a check-in count can. + shortfall: false, + }; + } + const ticked = new Set(logged); const count = scope.filter((k) => k <= today && ticked.has(k)).length; - const open = - goal.period === "event" && run === null - ? null - : scope.filter((k) => k >= today && !ticked.has(k)).length; + const open = unknowable ? null : scope.filter((k) => k >= today && !ticked.has(k)).length; const needed = Math.max(0, goal.required - count); return { scope: goal.period, + measure: "days", + periodStart, required: goal.required, count, met: count >= goal.required, @@ -116,16 +170,25 @@ export function goalStatus(input: { } const days = (n: number) => (n === 1 ? "1 day" : `${n} days`); +/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */ +const num = (n: number) => n.toLocaleString("en-US"); +/** " this week", " this month", or nothing for a whole event. */ +const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`); +const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`); /** * The checklist's line. An unticked past day is not "missed" here: every day * was never required, and calling it a failure would be the app inventing one. */ export function goalCaption(s: GoalStatus): string { - const tally = `${s.count} of ${s.required}`; - if (s.met) return `${tally} · ${s.scope === "week" ? "Done this week" : "Goal reached"}`; - const head = s.scope === "week" ? `${tally} this week` : tally; - const left = s.scope === "week" ? " this week" : ""; + const tally = `${num(s.count)} of ${num(s.required)}`; + if (s.met) { + const done = doneLabel(s); + return `${tally} · ${done[0]!.toUpperCase()}${done.slice(1)}`; + } + const head = `${tally}${within(s)}`; + if (s.measure === "amount") return `${head} · ${num(s.needed)} to go`; + const left = within(s); if (s.open === null) return `${head} · ${s.needed} needed`; if (s.shortfall) return `${head} · ${s.needed} needed, ${days(s.open)} left${left}`; return `${head} · ${s.needed} needed · ${days(s.open)} left${left}`; @@ -133,14 +196,14 @@ export function goalCaption(s: GoalStatus): string { /** The Dailies chip's short count. */ export function goalCount(s: GoalStatus): string { - return `${s.count}/${s.required}${s.scope === "week" ? " wk" : ""}`; + const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : ""; + return `${num(s.count)}/${num(s.required)}${unit}`; } /** Spoken state for the chip, after "done today" / "not done today". */ export function goalLabel(s: GoalStatus): string { - if (s.met) return s.scope === "week" ? "done this week" : "goal reached"; - const tally = `${s.count} of ${s.required}`; - return s.scope === "week" ? `${tally} this week` : tally; + if (s.met) return doneLabel(s); + return `${num(s.count)} of ${num(s.required)}${within(s)}`; } /** Keeps an edited count inside what the goal can mean. */ diff --git a/test/goal.test.ts b/test/goal.test.ts index 885de88..5f2f3ee 100644 --- a/test/goal.test.ts +++ b/test/goal.test.ts @@ -4,6 +4,7 @@ import { goalCaption, goalCount, goalLabel, + goalRequired, goalStatus, readGoal, type Goal, @@ -31,9 +32,8 @@ describe("readGoal", () => { test("absent, malformed or future-shaped is undefined, and never throws", () => { expect(readGoal(undefined)).toBeUndefined(); expect(readGoal({ period: "week", required: 9 })).toBeUndefined(); - expect( - readGoal({ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }), - ).toBeUndefined(); + // A shape no version yet writes stands in for "newer than this build". + expect(readGoal({ period: "fortnight", measure: "streak", required: 3 })).toBeUndefined(); expect(readGoal("five")).toBeUndefined(); }); }); @@ -161,8 +161,8 @@ describe("goalStatus: across the event", () => { describe("copy", () => { const status = (over: Partial): GoalStatus => ({ - scope: "week", required: 5, count: 3, met: false, needed: 2, - open: 4, shortfall: false, ...over, + scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 3, + met: false, needed: 2, open: 4, shortfall: false, ...over, }); test("captions", () => { @@ -201,3 +201,81 @@ describe("clampRequired", () => { expect(clampRequired("event", Number.NaN, 14)).toBe(1); }); }); + +describe("amount goals", () => { + const points: Goal = { period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }; + const amounts = { + "2026-10-05": { value: 2100, at: "2026-10-11T12:00:00.000Z" }, + "2026-10-12": { value: 1900, at: "2026-10-15T12:00:00.000Z" }, + }; + + test("reads amount shapes, and still refuses one with no target", () => { + expect(readGoal(points)).toEqual(points); + expect(readGoal({ period: "week", measure: "amount", weekStartsOn: 1 })).toBeUndefined(); + expect(readGoal({ period: "event", measure: "amount", target: 0 })).toBeUndefined(); + }); + + test("counts this week's stored value, not last week's", () => { + const s = goalStatus({ ...base, goal: points, now: noon("2026-10-15"), logged: [], amounts }); + expect(s).toMatchObject({ + measure: "amount", scope: "week", periodStart: "2026-10-12", + count: 1900, required: 2100, met: false, needed: 200, shortfall: false, + }); + }); + + test("a new week starts at nothing", () => { + const s = goalStatus({ ...base, goal: points, now: noon("2026-10-19"), logged: [], amounts }); + expect(s.count).toBe(0); + expect(s.periodStart).toBe("2026-10-19"); + }); + + test("met at or above the target", () => { + const s = goalStatus({ ...base, goal: points, now: noon("2026-10-11"), logged: [], amounts }); + expect(s.met).toBe(true); + }); + + test("an event-wide amount is keyed by the event's first game-day", () => { + const s = goalStatus({ + ...base, goal: { period: "event", measure: "amount", target: 500 }, + now: noon("2026-10-15"), logged: [], + amounts: { "2026-08-10": { value: 600, at: "2026-10-15T12:00:00.000Z" } }, + }); + expect(s).toMatchObject({ scope: "event", periodStart: "2026-08-10", count: 600, met: true }); + }); + + test("days goals report their measure and period start too", () => { + const s = goalStatus({ ...base, goal: weekly, now: noon("2026-10-15"), logged: [] }); + expect(s).toMatchObject({ measure: "days", periodStart: "2026-10-12" }); + }); +}); + +describe("copy for amounts and months", () => { + const status = (over: Partial): GoalStatus => ({ + scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, + count: 1900, met: false, needed: 200, open: 4, shortfall: false, ...over, + }); + + test("amount captions, counts and labels group thousands", () => { + expect(goalCaption(status({}))).toBe("1,900 of 2,100 this week · 200 to go"); + expect(goalCaption(status({ count: 2100, met: true, needed: 0 }))).toBe( + "2,100 of 2,100 · Done this week", + ); + expect(goalCount(status({}))).toBe("1,900/2,100 wk"); + expect(goalLabel(status({}))).toBe("1,900 of 2,100 this week"); + }); + + test("month scope", () => { + const month = status({ scope: "month", measure: "days", required: 1, count: 0, needed: 1 }); + expect(goalCount(month)).toBe("0/1 mo"); + expect(goalLabel(month)).toBe("0 of 1 this month"); + expect(goalLabel({ ...month, count: 1, met: true, needed: 0 })).toBe("done this month"); + expect(goalCaption({ ...month, count: 1, met: true, needed: 0 })).toBe( + "1 of 1 · Done this month", + ); + }); + + test("goalRequired reads either measure", () => { + expect(goalRequired(weekly)).toBe(5); + expect(goalRequired({ period: "event", measure: "amount", target: 2100 })).toBe(2100); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index b11746b..fe2b8ff 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -1170,8 +1170,8 @@ describe("goals in the dailies strip", () => { sourceUrl: "https://example.test", } as never; const status = (over: Partial = {}): GoalStatus => ({ - scope: "week", required: 5, count: 3, met: false, needed: 2, - open: 4, shortfall: false, ...over, + scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 3, + met: false, needed: 2, open: 4, shortfall: false, ...over, }); const strip = (goal: GoalStatus) => render( @@ -1280,7 +1280,8 @@ describe("DailyChecklist with a goal", () => { logged={["2026-10-12"]} onToggleDay={() => {}} goal={{ - scope: "week", required: 5, count: 1, met: false, needed: 4, open: 4, shortfall: false, + scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 1, + met: false, needed: 4, open: 4, shortfall: false, }} />, );