diff --git a/src/client/App.tsx b/src/client/App.tsx index 5db5bd6..c68b239 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -789,6 +789,8 @@ export function App() { dailyDays={daily.daysFor(openRow.event.id)} goal={goalOf(openRow.event.id)} hasStoredGoal={prog.progress[openRow.event.id]?.goal !== undefined} + amounts={amounts.amountsFor(openRow.event.id)} + onSetAmount={amounts.setAmount} onDaily={prog.setDaily} onGoal={prog.setGoal} onToggleDay={daily.toggleDay} diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 07fc805..3def4d6 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -20,7 +20,8 @@ import { ProgressControls } from "./ProgressControls.tsx"; import { DailyChecklist } from "./DailyChecklist.tsx"; import { GoalControl } from "./GoalControl.tsx"; import { dailyDays as runDays, dailyOverride, dayKey } from "../../shared/daily.ts"; -import { goalStatus, type Goal } from "../../shared/goal.ts"; +import { goalStatus, type AmountLog, type Goal } from "../../shared/goal.ts"; +import { AmountEditor } from "./AmountEditor.tsx"; import { isoWeekday } from "../../shared/period.ts"; import type { Region } from "../../shared/schema.ts"; import { Meter, URGENCY_COLOR } from "./Meter.tsx"; @@ -39,6 +40,8 @@ export function EventDetail({ dailyDays, goal, hasStoredGoal, + amounts, + onSetAmount, onDaily, onGoal, onToggleDay, @@ -71,6 +74,9 @@ export function EventDetail({ * the warning keys off this rather than off `goal`. */ hasStoredGoal: boolean; + /** Numbers entered for this event's points goal, by period start. */ + amounts: AmountLog; + onSetAmount: (id: string, periodStart: string, value: number) => void; onDaily: (id: string, daily: boolean | undefined, clearsGoal?: boolean) => void; onGoal: (id: string, goal: Goal | undefined) => void; onToggleDay: (id: string, day: string) => void; @@ -112,6 +118,7 @@ export function EventDetail({ game: event.game, now, logged: dailyDays, + amounts, }); const game = gameMeta(event.game); const heat = URGENCY_COLOR[clock.urgency]; @@ -239,6 +246,17 @@ export function EventDetail({ maxDays={run === null ? null : run.length} onChange={(g) => onGoal(event.id, g)} /> + {/* Always open here: the sheet has the room, and a number is the + one thing about this goal the reader comes back to change. */} + {goalState?.measure === "amount" && ( + onSetAmount(event.id, goalState.periodStart, value)} + /> + )} {/* Reaching the count may not have claimed the rewards, so the event is never finished on the reader's behalf — this is the way to say it is, offered at the moment it could be true. */} diff --git a/src/client/components/GoalControl.tsx b/src/client/components/GoalControl.tsx index e305176..f18fb80 100644 --- a/src/client/components/GoalControl.tsx +++ b/src/client/components/GoalControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { clampRequired, type Goal } from "../../shared/goal.ts"; +import { parseAmount } from "./AmountEditor.tsx"; const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; @@ -11,6 +12,34 @@ const MODES = [ type Mode = (typeof MODES)[number]["id"]; +const MEASURES = [ + { id: "days", label: "Days" }, + { id: "amount", label: "Points" }, +] as const; + +/** A points target nobody has typed yet: round, small, and obviously a placeholder. */ +const DEFAULT_TARGET = 100; +/** A days count nobody has typed yet. */ +const DEFAULT_REQUIRED = 5; + +/** The same goal over another period, keeping its measure and its number. */ +function overPeriod( + goal: Goal | undefined, + period: "week" | "event", + weekStartsOn: number, + maxDays: number | null, +): Goal { + if (goal?.measure === "amount") { + return period === "week" + ? { period, measure: "amount", target: goal.target, weekStartsOn } + : { period, measure: "amount", target: goal.target }; + } + const required = goal?.measure === "days" ? goal.required : DEFAULT_REQUIRED; + return period === "week" + ? { period, measure: "days", required: clampRequired("week", required, null), weekStartsOn } + : { period, measure: "days", required: clampRequired("event", required, maxDays) }; +} + /** * The goal a mode press asks for: a Goal, undefined to clear it, or null for * "nothing to do". Pressing the mode already shown is null — including Off @@ -25,16 +54,27 @@ export function chooseGoal( ): Goal | undefined | null { if (id === (goal?.period ?? "off")) return null; if (id === "off") return undefined; - const required = goal?.measure === "days" ? goal.required : 5; - if (id === "week") { - return { - period: "week", - measure: "days", - required: clampRequired("week", required, null), - weekStartsOn: goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn, - }; + const weekStartsOn = goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn; + return overPeriod(goal, id, weekStartsOn, maxDays); +} + +/** + * The goal a measure press asks for — days ticked, or a number against a + * target — over the same period; null when it is already that measure. + */ +export function chooseMeasure( + measure: Goal["measure"], + goal: Goal, + maxDays: number | null, +): Goal | null { + if (measure === goal.measure) return null; + const weekStartsOn = goal.period === "week" ? goal.weekStartsOn : 1; + if (measure === "amount") { + return goal.period === "week" + ? { period: "week", measure: "amount", target: DEFAULT_TARGET, weekStartsOn } + : { period: "event", measure: "amount", target: DEFAULT_TARGET }; } - return { period: "event", measure: "days", required: clampRequired("event", required, maxDays) }; + return overPeriod(undefined, goal.period, weekStartsOn, maxDays); } /** A typed count, clamped; null while the field is empty or unreadable — mid-edit, not an answer. */ @@ -101,13 +141,49 @@ export function GoalControl({ })} + {goal !== undefined && ( + // Days ticked, or a number against a target: 2,100 activity points a + // week is a goal too, and it is not a count of days. +
+ {MEASURES.map((m) => { + const on = goal.measure === m.id; + return ( + + ); + })} +
+ )} + {goal !== undefined && (
- {goal.measure === "days" && ( + {goal.measure === "days" ? ( + ) : ( + )} - {goal.period === "week" ? "days each week, weeks start" : "days across the event"} + {goal.measure === "amount" + ? goal.period === "week" + ? "points each week, weeks start" + : "points in total" + : goal.period === "week" + ? "days each week, weeks start" + : "days across the event"} {goal.period === "week" && ( { + setDraft(e.target.value); + const n = parseAmount(e.target.value); + // Zero is not a target; leave it as a draft until it is one. + // Capped at what the schema reads back: a larger number would be stored + // and then read as no goal at all. + const target = n === null ? null : Math.min(n, 1_000_000_000); + if (target !== null && target > 0 && target !== goal.target) { + onChange({ ...goal, target }); + } + }} + onBlur={() => setDraft(String(goal.target))} + className="tnum w-20 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none" + /> + ); +} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index 7c2d6a6..2d61014 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -785,6 +785,8 @@ describe("the derived-boundary note", () => { dailyDays: [], goal: undefined, hasStoredGoal: false, + amounts: {}, + onSetAmount: noop, onDaily: noop, onGoal: noop, onToggleDay: noop, @@ -872,6 +874,24 @@ describe("the derived-boundary note", () => { expect(sheet("done")).not.toContain("Mark event done"); }); + test("a points goal shows this week's number, editable in place", () => { + const html = renderToStaticMarkup( + metaFor(id, GAMES)}> + + , + ); + expect(html).toContain("1,900 of 2,100 this week · 200 to go"); + expect(html).toContain('value="1900"'); + expect(html).toContain("/ 2,100"); + }); + test("'not daily' warns it removes a goal even when this build cannot read it", () => { const html = renderToStaticMarkup( metaFor(id, GAMES)}> diff --git a/test/views.test.tsx b/test/views.test.tsx index f372d93..8c803ce 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -17,7 +17,12 @@ import { } from "../src/client/components/Dailies.tsx"; import type { GoalStatus } from "../src/shared/goal.ts"; import { Welcome } from "../src/client/components/Welcome.tsx"; -import { chooseGoal, GoalControl, parseDraft } from "../src/client/components/GoalControl.tsx"; +import { + chooseGoal, + chooseMeasure, + GoalControl, + parseDraft, +} from "../src/client/components/GoalControl.tsx"; import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx"; import type { Task } from "../src/shared/task.ts"; import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx"; @@ -1423,3 +1428,32 @@ describe("AmountEditor", () => { expect(parseAmount("-5")).toBeNull(); }); }); + +describe("GoalControl with points", () => { + const points = { period: "week", measure: "amount", target: 2100, weekStartsOn: 3 } as const; + + test("switching period keeps the measure and the number", () => { + expect(chooseGoal("event", points, 1, 84)).toEqual({ + period: "event", measure: "amount", target: 2100, + }); + expect(chooseGoal("week", { period: "event", measure: "amount", target: 50 }, 2, 84)).toEqual({ + period: "week", measure: "amount", target: 50, weekStartsOn: 2, + }); + }); + + test("switching measure keeps the period", () => { + const days = { period: "week", measure: "days", required: 5, weekStartsOn: 3 } as const; + expect(chooseMeasure("amount", days, 84)).toEqual({ ...points, target: 100 }); + expect(chooseMeasure("days", points, 84)).toEqual({ ...days, required: 5 }); + expect(chooseMeasure("days", days, 84)).toBeNull(); + }); + + test("a points goal shows its target and the measure toggle", () => { + const html = render( + {}} />, + ); + expect(html).toMatch(/aria-pressed="true"[^>]*>Points