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:
1 parent
a61a903583
commit
3c668ab5c1
6 files changed
+265
-4
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't a daily event — removes the goal");
|
||||
expect(sheet("done")).not.toContain("Mark event done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the cadence control", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user