diff --git a/src/client/App.tsx b/src/client/App.tsx index e610ccd..31ed848 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -760,7 +760,9 @@ export function App() { daily={repeatsDaily(openRow)} detectedDaily={prefs.detectDaily && isDaily(openRow.event)} dailyDays={daily.daysFor(openRow.event.id)} + goal={goalOf(openRow.event.id)} onDaily={prog.setDaily} + onGoal={prog.setGoal} onToggleDay={daily.toggleDay} onStatus={prog.setStatus} onEffort={prog.setEffort} diff --git a/src/client/components/DailyChecklist.tsx b/src/client/components/DailyChecklist.tsx index cda20b5..e2177d7 100644 --- a/src/client/components/DailyChecklist.tsx +++ b/src/client/components/DailyChecklist.tsx @@ -7,6 +7,7 @@ import { } from "../../shared/daily.ts"; import type { LaneId } from "../../shared/custom.ts"; import { DayPip } from "./DayPip.tsx"; +import { goalCaption, type GoalStatus } from "../../shared/goal.ts"; import type { Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; @@ -31,6 +32,7 @@ export function DailyChecklist({ now, logged, onToggleDay, + goal, }: { startsMs: number; endsMs: number | null; @@ -40,6 +42,12 @@ export function DailyChecklist({ now: number; logged: string[]; onToggleDay: (day: string) => void; + /** + * The reader's check-in goal, when set. The caption then counts against it + * instead of reporting missed days: an event that never wanted every day has + * not been failed on the ones left unticked. + */ + goal?: GoalStatus | undefined; }) { const summary = dailySummary({ startsMs, endsMs, region, game, now, logged }); const { days, today, doneToday, todayInWindow } = summary; @@ -115,6 +123,9 @@ export function DailyChecklist({ /> ))} + {goal !== undefined && ( +

{goalCaption(goal)}

+ )}

The source hasn't announced an end date, so how many days are left is unknown. Your ticks are still counted — {summary.logged} so far, and @@ -136,7 +147,7 @@ export function DailyChecklist({ ))}

- {caption(summary, todayInWindow)} + {goal !== undefined ? goalCaption(goal) : caption(summary, todayInWindow)}

)} diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index d6e33a3..020b6e3 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -18,7 +18,10 @@ import { pressure, pressureReason, type Effort } from "../../shared/effort.ts"; import type { Status } from "../state/useProgress.ts"; import { ProgressControls } from "./ProgressControls.tsx"; import { DailyChecklist } from "./DailyChecklist.tsx"; -import { dailyOverride } from "../../shared/daily.ts"; +import { GoalControl } from "./GoalControl.tsx"; +import { dailyDays as runDays, dailyOverride, dayKey } from "../../shared/daily.ts"; +import { goalStatus, type Goal } from "../../shared/goal.ts"; +import { isoWeekday } from "../../shared/period.ts"; import type { Region } from "../../shared/schema.ts"; import { Meter, URGENCY_COLOR } from "./Meter.tsx"; @@ -34,7 +37,9 @@ export function EventDetail({ daily, detectedDaily, dailyDays, + goal, onDaily, + onGoal, onToggleDay, onIgnore, onStatus, @@ -57,7 +62,10 @@ export function EventDetail({ detectedDaily: boolean; /** Days already ticked off, for events that repeat. */ dailyDays: string[]; + /** The reader's check-in goal, already read through `readGoal`. */ + goal: Goal | undefined; onDaily: (id: string, daily: boolean | undefined) => void; + onGoal: (id: string, goal: Goal | undefined) => void; onToggleDay: (id: string, day: string) => void; onIgnore: (id: string) => void; onStatus: (id: string, s: Status | undefined) => void; @@ -83,6 +91,21 @@ export function EventDetail({ const gameMeta = useGameMeta(); const [editing, setEditing] = useState(false); const { event, clock } = row; + // The run's claimable days caps an event-wide goal; null when the end is + // unannounced, and then nothing is invented to cap it with. + const run = runDays(clock.startsMs, clock.endsMs, region, event.game); + const goalState = + goal === undefined + ? undefined + : goalStatus({ + goal, + startsMs: clock.startsMs, + endsMs: clock.endsMs, + region, + game: event.game, + now, + logged: dailyDays, + }); const game = gameMeta(event.game); const heat = URGENCY_COLOR[clock.urgency]; const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining); @@ -201,13 +224,34 @@ export function EventDetail({ now={now} logged={dailyDays} onToggleDay={(day) => onToggleDay(event.id, day)} + goal={goalState} /> + onGoal(event.id, g)} + /> + {/* 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. */} + {goalState?.scope === "event" && goalState.met && status !== "done" && ( + + )} ) : ( diff --git a/src/client/components/GoalControl.tsx b/src/client/components/GoalControl.tsx new file mode 100644 index 0000000..0552e6f --- /dev/null +++ b/src/client/components/GoalControl.tsx @@ -0,0 +1,117 @@ +import { clampRequired, type Goal } from "../../shared/goal.ts"; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +const MODES = [ + { id: "off", label: "Off" }, + { id: "week", label: "Each week" }, + { id: "event", label: "Across the event" }, +] as const; + +/** + * How many check-ins the event actually wants. + * + * Some events ask for five days out of seven, not every day. The reader says + * so here — nothing is read off the title — and the checklist and the dailies + * strip count against it. Meeting it never marks the event done. + * + * The same pill style as the status and effort controls below it, because it + * is the same kind of answer: something only the reader knows. + */ +export function GoalControl({ + goal, + defaultWeekStartsOn, + maxDays, + onChange, +}: { + goal: Goal | undefined; + /** The weekday of the event's first game-day: its weeks most likely start there. */ + defaultWeekStartsOn: number; + /** Claimable days in the run, or null when the end is unannounced. */ + maxDays: number | null; + onChange: (goal: Goal | undefined) => void; +}) { + const mode = goal?.period ?? "off"; + const required = goal?.required ?? 5; + + const choose = (id: (typeof MODES)[number]["id"]) => { + if (id === "off") return onChange(undefined); + if (id === "week") { + return onChange({ + period: "week", + measure: "days", + required: clampRequired("week", required, null), + weekStartsOn: goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn, + }); + } + onChange({ + period: "event", + measure: "days", + required: clampRequired("event", required, maxDays), + }); + }; + + const setRequired = (raw: string) => { + if (goal === undefined) return; + // An emptied field is mid-edit, not an answer; keep the goal as it was. + if (raw.trim() === "") return; + onChange({ ...goal, required: clampRequired(goal.period, Number.parseInt(raw, 10), maxDays) }); + }; + + return ( +
+

Check-in goal

+
+ {MODES.map((m) => { + const on = mode === m.id; + return ( + + ); + })} +
+ + {goal !== undefined && ( +
+ setRequired(e.target.value)} + className="tnum w-14 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none" + /> + + {goal.period === "week" ? "days each week, weeks start" : "days across the event"} + + {goal.period === "week" && ( + + )} +
+ )} +
+ ); +} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index 187e32f..04d099d 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -783,7 +783,9 @@ describe("the derived-boundary note", () => { daily: false, detectedDaily: false, dailyDays: [], + goal: undefined, onDaily: noop, + onGoal: noop, onToggleDay: noop, onIgnore: noop, onStatus: noop, @@ -844,6 +846,29 @@ describe("the derived-boundary note", () => { ); expect(html).not.toContain("server reset"); }); + + // Reaching an event-wide check-in count may not have claimed the rewards, + // so the sheet offers "Mark event done" and never does it on its own. + test("a reached event-wide goal offers to mark the event done, once", () => { + const sheet = (status: "done" | undefined) => + renderToStaticMarkup( + metaFor(id, GAMES)}> + + , + ); + const open = sheet(undefined); + expect(open).toContain("5 of 5 · Goal reached"); + expect(open).toContain("Mark event done"); + expect(open).toContain("This isn't a daily event — removes the goal"); + expect(sheet("done")).not.toContain("Mark event done"); + }); }); describe("the cadence control", () => { diff --git a/test/views.test.tsx b/test/views.test.tsx index 0d5f3a5..2c23b70 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -17,6 +17,8 @@ import { } from "../src/client/components/Dailies.tsx"; import type { GoalStatus } from "../src/shared/goal.ts"; import { Welcome } from "../src/client/components/Welcome.tsx"; +import { GoalControl } from "../src/client/components/GoalControl.tsx"; +import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx"; import { timelineLanes } from "../src/client/state/lanes.ts"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; import { metaFor } from "../src/shared/games.ts"; @@ -1226,3 +1228,63 @@ describe("goals in the dailies strip", () => { ); }); }); + +describe("GoalControl", () => { + test("off shows the three choices with Off pressed", () => { + const html = render( + {}} />, + ); + expect(html).toContain("Check-in goal"); + expect(html).toMatch(/aria-pressed="true"[^>]*>Off { + const html = render( + {}} + />, + ); + expect(html).toContain('value="5"'); + expect(html).toMatch(/