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({
))}
>
)}
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 (
+