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"]; const MODES = [ { id: "off", label: "Off" }, { id: "week", label: "Each week" }, { id: "event", label: "Across the event" }, ] as const; 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 * when no goal is *readable*, which may still be one stored by a newer * version, and a press that looks like a no-op must not delete it. */ export function chooseGoal( id: Mode, goal: Goal | undefined, defaultWeekStartsOn: number, maxDays: number | null, ): Goal | undefined | null { if (id === (goal?.period ?? "off")) return null; if (id === "off") return undefined; 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 overPeriod(undefined, goal.period, weekStartsOn, maxDays); } /** A typed count, clamped; null while the field is empty or unreadable — mid-edit, not an answer. */ export function parseDraft( raw: string, period: Goal["period"], maxDays: number | null, ): number | null { const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? clampRequired(period, n, maxDays) : null; } /** * 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 choose = (id: Mode) => { const next = chooseGoal(id, goal, defaultWeekStartsOn, maxDays); if (next !== null) onChange(next); }; return (
Check-in goal