feat(detail): let an event's goal be points, entered in the sheet
Some events want a number of points rather than a number of days. The goal control gains Days and Points. Switching period keeps the measure and the number, and the sheet keeps this period's number editable in place. A target is capped where the schema reads it back, so a huge number can't silently become no goal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
595ae55f4a
commit
d0b295c02f
@@ -789,6 +789,8 @@ export function App() {
|
||||
dailyDays={daily.daysFor(openRow.event.id)}
|
||||
goal={goalOf(openRow.event.id)}
|
||||
hasStoredGoal={prog.progress[openRow.event.id]?.goal !== undefined}
|
||||
amounts={amounts.amountsFor(openRow.event.id)}
|
||||
onSetAmount={amounts.setAmount}
|
||||
onDaily={prog.setDaily}
|
||||
onGoal={prog.setGoal}
|
||||
onToggleDay={daily.toggleDay}
|
||||
|
||||
@@ -20,7 +20,8 @@ import { ProgressControls } from "./ProgressControls.tsx";
|
||||
import { DailyChecklist } from "./DailyChecklist.tsx";
|
||||
import { GoalControl } from "./GoalControl.tsx";
|
||||
import { dailyDays as runDays, dailyOverride, dayKey } from "../../shared/daily.ts";
|
||||
import { goalStatus, type Goal } from "../../shared/goal.ts";
|
||||
import { goalStatus, type AmountLog, type Goal } from "../../shared/goal.ts";
|
||||
import { AmountEditor } from "./AmountEditor.tsx";
|
||||
import { isoWeekday } from "../../shared/period.ts";
|
||||
import type { Region } from "../../shared/schema.ts";
|
||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
@@ -39,6 +40,8 @@ export function EventDetail({
|
||||
dailyDays,
|
||||
goal,
|
||||
hasStoredGoal,
|
||||
amounts,
|
||||
onSetAmount,
|
||||
onDaily,
|
||||
onGoal,
|
||||
onToggleDay,
|
||||
@@ -71,6 +74,9 @@ export function EventDetail({
|
||||
* the warning keys off this rather than off `goal`.
|
||||
*/
|
||||
hasStoredGoal: boolean;
|
||||
/** Numbers entered for this event's points goal, by period start. */
|
||||
amounts: AmountLog;
|
||||
onSetAmount: (id: string, periodStart: string, value: number) => void;
|
||||
onDaily: (id: string, daily: boolean | undefined, clearsGoal?: boolean) => void;
|
||||
onGoal: (id: string, goal: Goal | undefined) => void;
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
@@ -112,6 +118,7 @@ export function EventDetail({
|
||||
game: event.game,
|
||||
now,
|
||||
logged: dailyDays,
|
||||
amounts,
|
||||
});
|
||||
const game = gameMeta(event.game);
|
||||
const heat = URGENCY_COLOR[clock.urgency];
|
||||
@@ -239,6 +246,17 @@ export function EventDetail({
|
||||
maxDays={run === null ? null : run.length}
|
||||
onChange={(g) => onGoal(event.id, g)}
|
||||
/>
|
||||
{/* Always open here: the sheet has the room, and a number is the
|
||||
one thing about this goal the reader comes back to change. */}
|
||||
{goalState?.measure === "amount" && (
|
||||
<AmountEditor
|
||||
key={goalState.periodStart}
|
||||
label={goalState.scope === "event" ? "So far:" : "This week:"}
|
||||
value={amounts[goalState.periodStart]?.value}
|
||||
target={goalState.required}
|
||||
onSave={(value) => onSetAmount(event.id, goalState.periodStart, value)}
|
||||
/>
|
||||
)}
|
||||
{/* 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. */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"];
|
||||
|
||||
@@ -11,6 +12,34 @@ const MODES = [
|
||||
|
||||
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
|
||||
@@ -25,16 +54,27 @@ export function chooseGoal(
|
||||
): Goal | undefined | null {
|
||||
if (id === (goal?.period ?? "off")) return null;
|
||||
if (id === "off") return undefined;
|
||||
const required = goal?.measure === "days" ? goal.required : 5;
|
||||
if (id === "week") {
|
||||
return {
|
||||
period: "week",
|
||||
measure: "days",
|
||||
required: clampRequired("week", required, null),
|
||||
weekStartsOn: goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn,
|
||||
};
|
||||
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 { period: "event", measure: "days", required: clampRequired("event", required, maxDays) };
|
||||
return overPeriod(undefined, goal.period, weekStartsOn, maxDays);
|
||||
}
|
||||
|
||||
/** A typed count, clamped; null while the field is empty or unreadable — mid-edit, not an answer. */
|
||||
@@ -101,13 +141,49 @@ export function GoalControl({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{goal !== undefined && (
|
||||
// Days ticked, or a number against a target: 2,100 activity points a
|
||||
// week is a goal too, and it is not a count of days.
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{MEASURES.map((m) => {
|
||||
const on = goal.measure === m.id;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = chooseMeasure(m.id, goal, maxDays);
|
||||
if (next !== null) onChange(next);
|
||||
}}
|
||||
aria-pressed={on}
|
||||
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] 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">
|
||||
{goal.measure === "days" && (
|
||||
{goal.measure === "days" ? (
|
||||
<CountField goal={goal} maxDays={maxDays} onChange={onChange} />
|
||||
) : (
|
||||
<TargetField goal={goal} onChange={onChange} />
|
||||
)}
|
||||
<span>
|
||||
{goal.period === "week" ? "days each week, weeks start" : "days across the event"}
|
||||
{goal.measure === "amount"
|
||||
? goal.period === "week"
|
||||
? "points each week, weeks start"
|
||||
: "points in total"
|
||||
: goal.period === "week"
|
||||
? "days each week, weeks start"
|
||||
: "days across the event"}
|
||||
</span>
|
||||
{goal.period === "week" && (
|
||||
<select
|
||||
@@ -165,3 +241,36 @@ function CountField({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The points target, drafted the way the count is, for the same reason. */
|
||||
function TargetField({
|
||||
goal,
|
||||
onChange,
|
||||
}: {
|
||||
goal: Extract<Goal, { measure: "amount" }>;
|
||||
onChange: (goal: Goal) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(String(goal.target));
|
||||
useEffect(() => setDraft(String(goal.target)), [goal.target]);
|
||||
|
||||
return (
|
||||
<input
|
||||
inputMode="numeric"
|
||||
aria-label="Points needed"
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
const n = parseAmount(e.target.value);
|
||||
// Zero is not a target; leave it as a draft until it is one.
|
||||
// Capped at what the schema reads back: a larger number would be stored
|
||||
// and then read as no goal at all.
|
||||
const target = n === null ? null : Math.min(n, 1_000_000_000);
|
||||
if (target !== null && target > 0 && target !== goal.target) {
|
||||
onChange({ ...goal, target });
|
||||
}
|
||||
}}
|
||||
onBlur={() => setDraft(String(goal.target))}
|
||||
className="tnum w-20 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -785,6 +785,8 @@ describe("the derived-boundary note", () => {
|
||||
dailyDays: [],
|
||||
goal: undefined,
|
||||
hasStoredGoal: false,
|
||||
amounts: {},
|
||||
onSetAmount: noop,
|
||||
onDaily: noop,
|
||||
onGoal: noop,
|
||||
onToggleDay: noop,
|
||||
@@ -872,6 +874,24 @@ describe("the derived-boundary note", () => {
|
||||
expect(sheet("done")).not.toContain("Mark event done");
|
||||
});
|
||||
|
||||
test("a points goal shows this week's number, editable in place", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
||||
<EventDetail
|
||||
{...detailProps}
|
||||
daily
|
||||
goal={{ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }}
|
||||
hasStoredGoal
|
||||
amounts={{ "2026-08-24": { value: 1900, at: AT } }}
|
||||
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
||||
/>
|
||||
</GameMetaProvider>,
|
||||
);
|
||||
expect(html).toContain("1,900 of 2,100 this week · 200 to go");
|
||||
expect(html).toContain('value="1900"');
|
||||
expect(html).toContain("/ 2,100");
|
||||
});
|
||||
|
||||
test("'not daily' warns it removes a goal even when this build cannot read it", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
||||
|
||||
+35
-1
@@ -17,7 +17,12 @@ import {
|
||||
} from "../src/client/components/Dailies.tsx";
|
||||
import type { GoalStatus } from "../src/shared/goal.ts";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
import { chooseGoal, GoalControl, parseDraft } from "../src/client/components/GoalControl.tsx";
|
||||
import {
|
||||
chooseGoal,
|
||||
chooseMeasure,
|
||||
GoalControl,
|
||||
parseDraft,
|
||||
} from "../src/client/components/GoalControl.tsx";
|
||||
import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx";
|
||||
import type { Task } from "../src/shared/task.ts";
|
||||
import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx";
|
||||
@@ -1423,3 +1428,32 @@ describe("AmountEditor", () => {
|
||||
expect(parseAmount("-5")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoalControl with points", () => {
|
||||
const points = { period: "week", measure: "amount", target: 2100, weekStartsOn: 3 } as const;
|
||||
|
||||
test("switching period keeps the measure and the number", () => {
|
||||
expect(chooseGoal("event", points, 1, 84)).toEqual({
|
||||
period: "event", measure: "amount", target: 2100,
|
||||
});
|
||||
expect(chooseGoal("week", { period: "event", measure: "amount", target: 50 }, 2, 84)).toEqual({
|
||||
period: "week", measure: "amount", target: 50, weekStartsOn: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching measure keeps the period", () => {
|
||||
const days = { period: "week", measure: "days", required: 5, weekStartsOn: 3 } as const;
|
||||
expect(chooseMeasure("amount", days, 84)).toEqual({ ...points, target: 100 });
|
||||
expect(chooseMeasure("days", points, 84)).toEqual({ ...days, required: 5 });
|
||||
expect(chooseMeasure("days", days, 84)).toBeNull();
|
||||
});
|
||||
|
||||
test("a points goal shows its target and the measure toggle", () => {
|
||||
const html = render(
|
||||
<GoalControl goal={points} defaultWeekStartsOn={1} maxDays={84} onChange={() => {}} />,
|
||||
);
|
||||
expect(html).toMatch(/aria-pressed="true"[^>]*>Points</);
|
||||
expect(html).toContain('value="2100"');
|
||||
expect(html).toContain("points each week, weeks start");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user