feat(detail): set a check-in goal from the event's checklist

The goal sits under the checklist, where its count shows. With a goal
set, the caption counts against it instead of calling unticked days
missed. Reaching an event-wide goal offers Mark event done but never
takes it. Saying "not daily" says up front that it removes the goal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 13:14:07 +02:00
1 parent a61a903583
commit 3c668ab5c1
6 files changed
+265 -4

No files matched your search

+2
View File
@@ -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}
+12 -1
View File
@@ -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({
/>
))}
</div>
{goal !== undefined && (
<p className="mt-2.5 text-xs leading-relaxed text-muted">{goalCaption(goal)}</p>
)}
<p className="mt-2.5 text-xs leading-relaxed text-faint">
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({
))}
</div>
<p className="mt-2.5 text-xs leading-relaxed text-faint">
{caption(summary, todayInWindow)}
{goal !== undefined ? goalCaption(goal) : caption(summary, todayInWindow)}
</p>
</>
)}
+47 -3
View File
@@ -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}
/>
<GoalControl
goal={goal}
defaultWeekStartsOn={isoWeekday(dayKey(clock.startsMs, region, event.game))}
maxDays={run === null ? null : run.length}
onChange={(g) => 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" && (
<button
type="button"
onClick={() => onStatus(event.id, "done")}
className="mt-2 rounded-full border border-near/70 bg-near/15 px-3 py-1.5 text-xs font-medium text-near"
>
Mark event done
</button>
)}
<button
type="button"
onClick={() => onDaily(event.id, dailyOverride(false, detectedDaily))}
className="mt-2 text-xs text-faint transition-colors hover:text-muted"
className="mt-2 block text-xs text-faint transition-colors hover:text-muted"
>
This isn't a daily event
{goal === undefined
? "This isn't a daily event"
: "This isn't a daily event — removes the goal"}
</button>
</>
) : (
+117
View File
@@ -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 (
<div className="mt-3">
<p className="eyebrow">Check-in goal</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{MODES.map((m) => {
const on = mode === m.id;
return (
<button
key={m.id}
type="button"
onClick={() => choose(m.id)}
aria-pressed={on}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors duration-150 ${
on
? "border-near/70 bg-near/15 text-near"
: "border-hairline text-faint hover:text-muted"
}`}
>
{m.label}
</button>
);
})}
</div>
{goal !== undefined && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted">
<input
type="number"
aria-label="Days needed"
min={1}
max={goal.period === "week" ? 7 : (maxDays ?? 366)}
value={goal.required}
onChange={(e) => 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"
/>
<span>
{goal.period === "week" ? "days each week, weeks start" : "days across the event"}
</span>
{goal.period === "week" && (
<select
aria-label="Week starts on"
value={goal.weekStartsOn}
onChange={(e) => onChange({ ...goal, weekStartsOn: Number(e.target.value) })}
className="rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"
>
{WEEKDAYS.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
)}
</div>
)}
</div>
);
}
+25
View File
@@ -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(
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
<EventDetail
{...detailProps}
status={status}
daily
goal={{ period: "event", measure: "days", required: 5 }}
dailyDays={["2026-08-20", "2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24"]}
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
/>
</GameMetaProvider>,
);
const open = sheet(undefined);
expect(open).toContain("5 of 5 · Goal reached");
expect(open).toContain("Mark event done");
expect(open).toContain("This isn&#x27;t a daily event — removes the goal");
expect(sheet("done")).not.toContain("Mark event done");
});
});
describe("the cadence control", () => {
+62
View File
@@ -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(
<GoalControl goal={undefined} defaultWeekStartsOn={1} maxDays={84} onChange={() => {}} />,
);
expect(html).toContain("Check-in goal");
expect(html).toMatch(/aria-pressed="true"[^>]*>Off</);
expect(html).toContain("Each week");
expect(html).toContain("Across the event");
expect(html).not.toContain('type="number"');
});
test("a weekly goal shows its count and start day", () => {
const html = render(
<GoalControl
goal={{ period: "week", measure: "days", required: 5, weekStartsOn: 1 }}
defaultWeekStartsOn={1}
maxDays={84}
onChange={() => {}}
/>,
);
expect(html).toContain('value="5"');
expect(html).toMatch(/<option value="1" selected="">Monday<\/option>/);
});
test("an event-wide goal has no weekday", () => {
const html = render(
<GoalControl
goal={{ period: "event", measure: "days", required: 5 }}
defaultWeekStartsOn={1}
maxDays={7}
onChange={() => {}}
/>,
);
expect(html).toContain("days across the event");
expect(html).not.toContain("<select");
});
});
describe("DailyChecklist with a goal", () => {
test("the caption reports the goal, not missed days", () => {
const html = render(
<DailyChecklist
startsMs={Date.parse("2026-10-12T03:00:00Z")}
endsMs={Date.parse("2026-11-02T03:00:00Z")}
region="europe"
game="genshin"
now={Date.parse("2026-10-15T12:00:00Z")}
logged={["2026-10-12"]}
onToggleDay={() => {}}
goal={{
scope: "week", required: 5, count: 1, met: false, needed: 4, open: 4, shortfall: false,
}}
/>,
);
expect(html).toContain("1 of 5 this week · 4 needed · 4 days left this week");
expect(html).not.toContain("missed");
});
});