diff --git a/src/client/components/GoalControl.tsx b/src/client/components/GoalControl.tsx
index 4cc16e1..e305176 100644
--- a/src/client/components/GoalControl.tsx
+++ b/src/client/components/GoalControl.tsx
@@ -25,7 +25,7 @@ export function chooseGoal(
): Goal | undefined | null {
if (id === (goal?.period ?? "off")) return null;
if (id === "off") return undefined;
- const required = goal?.required ?? 5;
+ const required = goal?.measure === "days" ? goal.required : 5;
if (id === "week") {
return {
period: "week",
@@ -103,7 +103,9 @@ export function GoalControl({
{goal !== undefined && (
-
+ {goal.measure === "days" && (
+
+ )}
{goal.period === "week" ? "days each week, weeks start" : "days across the event"}
@@ -139,7 +141,7 @@ function CountField({
maxDays,
onChange,
}: {
- goal: Goal;
+ goal: Extract;
maxDays: number | null;
onChange: (goal: Goal) => void;
}) {
diff --git a/src/shared/goal.ts b/src/shared/goal.ts
index c3ef09e..249ff54 100644
--- a/src/shared/goal.ts
+++ b/src/shared/goal.ts
@@ -4,6 +4,9 @@ import { dailyDays, dayKey } from "./daily.ts";
import { addDays, periodOf, WeekdaySchema } from "./period.ts";
import type { Region } from "./schema.ts";
+/** A whole, positive number; anything a game would plausibly count to. */
+export const AmountTarget = z.number().int().min(1).max(1_000_000_000);
+
/**
* A check-in goal: the event wants N distinct game-days, per week or across its
* whole run, rather than every day.
@@ -28,9 +31,33 @@ export const GoalSchema = z.union([
measure: z.literal("days"),
required: z.number().int().min(1).max(366),
}),
+ // A number against a target — 2,100 activity points a week — entered by the
+ // reader per period and stored in the amounts store, not the day log.
+ z.object({
+ period: z.literal("week"),
+ measure: z.literal("amount"),
+ target: AmountTarget,
+ weekStartsOn: WeekdaySchema,
+ }),
+ z.object({
+ period: z.literal("event"),
+ measure: z.literal("amount"),
+ target: AmountTarget,
+ }),
]);
export type Goal = z.infer;
+/** What a goal asks for, in its own measure: days, or the target number. */
+export function goalRequired(goal: Goal): number {
+ return goal.measure === "days" ? goal.required : goal.target;
+}
+
+/**
+ * A reader's numbers for one item, by the start key of the period they were
+ * entered for. One value per period; a new period starts with none.
+ */
+export type AmountLog = Record;
+
/**
* A stored goal, or undefined when absent or unreadable.
*
@@ -45,9 +72,13 @@ export function readGoal(stored: unknown): Goal | undefined {
}
export interface GoalStatus {
- scope: "week" | "event";
+ /** Month is a task's period (task.ts); an event's goal is a week or the event. */
+ scope: "week" | "month" | "event";
+ measure: "days" | "amount";
+ /** The period's first game-day: the key an amount is stored under. */
+ periodStart: string;
required: number;
- /** Distinct ticked game-days in scope, never later than today. */
+ /** Distinct ticked game-days in scope, never later than today — or, for an amount, the value entered for this period. */
count: number;
met: boolean;
needed: number;
@@ -79,33 +110,56 @@ export function goalStatus(input: {
game: LaneId;
now: number;
logged: readonly string[];
+ /** This item's entered numbers, for an amount goal. */
+ amounts?: AmountLog | undefined;
}): GoalStatus {
- const { goal, startsMs, endsMs, region, game, now, logged } = input;
+ const { goal, startsMs, endsMs, region, game, now, logged, amounts } = input;
const today = dayKey(now, region, game);
const first = dayKey(startsMs, region, game);
const run = dailyDays(startsMs, endsMs, region, game);
let scope: string[];
+ let periodStart: string;
if (goal.period === "week") {
// The rule defines this week's boundary even when the event's end is
// unknown; the start still clips it, because a day before the event opened
// was never claimable.
- const week = periodOf({ unit: "week", startsOn: goal.weekStartsOn }, today).days;
- scope = run === null ? week.filter((k) => k >= first) : week.filter((k) => run.includes(k));
+ const week = periodOf({ unit: "week", startsOn: goal.weekStartsOn }, today);
+ periodStart = week.start;
+ scope =
+ run === null ? week.days.filter((k) => k >= first) : week.days.filter((k) => run.includes(k));
} else {
+ periodStart = first;
scope = run ?? keysFrom(first, today);
}
+ const unknowable = goal.period === "event" && run === null;
+ if (goal.measure === "amount") {
+ const count = amounts?.[periodStart]?.value ?? 0;
+ return {
+ scope: goal.period,
+ measure: "amount",
+ periodStart,
+ required: goal.target,
+ count,
+ met: count >= goal.target,
+ needed: Math.max(0, goal.target - count),
+ open: unknowable ? null : scope.filter((k) => k >= today).length,
+ // How much is left to earn per day is the game's business, not ours;
+ // a number cannot be short of days the way a check-in count can.
+ shortfall: false,
+ };
+ }
+
const ticked = new Set(logged);
const count = scope.filter((k) => k <= today && ticked.has(k)).length;
- const open =
- goal.period === "event" && run === null
- ? null
- : scope.filter((k) => k >= today && !ticked.has(k)).length;
+ const open = unknowable ? null : scope.filter((k) => k >= today && !ticked.has(k)).length;
const needed = Math.max(0, goal.required - count);
return {
scope: goal.period,
+ measure: "days",
+ periodStart,
required: goal.required,
count,
met: count >= goal.required,
@@ -116,16 +170,25 @@ export function goalStatus(input: {
}
const days = (n: number) => (n === 1 ? "1 day" : `${n} days`);
+/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */
+const num = (n: number) => n.toLocaleString("en-US");
+/** " this week", " this month", or nothing for a whole event. */
+const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`);
+const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`);
/**
* The checklist's line. An unticked past day is not "missed" here: every day
* was never required, and calling it a failure would be the app inventing one.
*/
export function goalCaption(s: GoalStatus): string {
- const tally = `${s.count} of ${s.required}`;
- if (s.met) return `${tally} · ${s.scope === "week" ? "Done this week" : "Goal reached"}`;
- const head = s.scope === "week" ? `${tally} this week` : tally;
- const left = s.scope === "week" ? " this week" : "";
+ const tally = `${num(s.count)} of ${num(s.required)}`;
+ if (s.met) {
+ const done = doneLabel(s);
+ return `${tally} · ${done[0]!.toUpperCase()}${done.slice(1)}`;
+ }
+ const head = `${tally}${within(s)}`;
+ if (s.measure === "amount") return `${head} · ${num(s.needed)} to go`;
+ const left = within(s);
if (s.open === null) return `${head} · ${s.needed} needed`;
if (s.shortfall) return `${head} · ${s.needed} needed, ${days(s.open)} left${left}`;
return `${head} · ${s.needed} needed · ${days(s.open)} left${left}`;
@@ -133,14 +196,14 @@ export function goalCaption(s: GoalStatus): string {
/** The Dailies chip's short count. */
export function goalCount(s: GoalStatus): string {
- return `${s.count}/${s.required}${s.scope === "week" ? " wk" : ""}`;
+ const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : "";
+ return `${num(s.count)}/${num(s.required)}${unit}`;
}
/** Spoken state for the chip, after "done today" / "not done today". */
export function goalLabel(s: GoalStatus): string {
- if (s.met) return s.scope === "week" ? "done this week" : "goal reached";
- const tally = `${s.count} of ${s.required}`;
- return s.scope === "week" ? `${tally} this week` : tally;
+ if (s.met) return doneLabel(s);
+ return `${num(s.count)} of ${num(s.required)}${within(s)}`;
}
/** Keeps an edited count inside what the goal can mean. */
diff --git a/test/goal.test.ts b/test/goal.test.ts
index 885de88..5f2f3ee 100644
--- a/test/goal.test.ts
+++ b/test/goal.test.ts
@@ -4,6 +4,7 @@ import {
goalCaption,
goalCount,
goalLabel,
+ goalRequired,
goalStatus,
readGoal,
type Goal,
@@ -31,9 +32,8 @@ describe("readGoal", () => {
test("absent, malformed or future-shaped is undefined, and never throws", () => {
expect(readGoal(undefined)).toBeUndefined();
expect(readGoal({ period: "week", required: 9 })).toBeUndefined();
- expect(
- readGoal({ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }),
- ).toBeUndefined();
+ // A shape no version yet writes stands in for "newer than this build".
+ expect(readGoal({ period: "fortnight", measure: "streak", required: 3 })).toBeUndefined();
expect(readGoal("five")).toBeUndefined();
});
});
@@ -161,8 +161,8 @@ describe("goalStatus: across the event", () => {
describe("copy", () => {
const status = (over: Partial): GoalStatus => ({
- scope: "week", required: 5, count: 3, met: false, needed: 2,
- open: 4, shortfall: false, ...over,
+ scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 3,
+ met: false, needed: 2, open: 4, shortfall: false, ...over,
});
test("captions", () => {
@@ -201,3 +201,81 @@ describe("clampRequired", () => {
expect(clampRequired("event", Number.NaN, 14)).toBe(1);
});
});
+
+describe("amount goals", () => {
+ const points: Goal = { period: "week", measure: "amount", target: 2100, weekStartsOn: 1 };
+ const amounts = {
+ "2026-10-05": { value: 2100, at: "2026-10-11T12:00:00.000Z" },
+ "2026-10-12": { value: 1900, at: "2026-10-15T12:00:00.000Z" },
+ };
+
+ test("reads amount shapes, and still refuses one with no target", () => {
+ expect(readGoal(points)).toEqual(points);
+ expect(readGoal({ period: "week", measure: "amount", weekStartsOn: 1 })).toBeUndefined();
+ expect(readGoal({ period: "event", measure: "amount", target: 0 })).toBeUndefined();
+ });
+
+ test("counts this week's stored value, not last week's", () => {
+ const s = goalStatus({ ...base, goal: points, now: noon("2026-10-15"), logged: [], amounts });
+ expect(s).toMatchObject({
+ measure: "amount", scope: "week", periodStart: "2026-10-12",
+ count: 1900, required: 2100, met: false, needed: 200, shortfall: false,
+ });
+ });
+
+ test("a new week starts at nothing", () => {
+ const s = goalStatus({ ...base, goal: points, now: noon("2026-10-19"), logged: [], amounts });
+ expect(s.count).toBe(0);
+ expect(s.periodStart).toBe("2026-10-19");
+ });
+
+ test("met at or above the target", () => {
+ const s = goalStatus({ ...base, goal: points, now: noon("2026-10-11"), logged: [], amounts });
+ expect(s.met).toBe(true);
+ });
+
+ test("an event-wide amount is keyed by the event's first game-day", () => {
+ const s = goalStatus({
+ ...base, goal: { period: "event", measure: "amount", target: 500 },
+ now: noon("2026-10-15"), logged: [],
+ amounts: { "2026-08-10": { value: 600, at: "2026-10-15T12:00:00.000Z" } },
+ });
+ expect(s).toMatchObject({ scope: "event", periodStart: "2026-08-10", count: 600, met: true });
+ });
+
+ test("days goals report their measure and period start too", () => {
+ const s = goalStatus({ ...base, goal: weekly, now: noon("2026-10-15"), logged: [] });
+ expect(s).toMatchObject({ measure: "days", periodStart: "2026-10-12" });
+ });
+});
+
+describe("copy for amounts and months", () => {
+ const status = (over: Partial): GoalStatus => ({
+ scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100,
+ count: 1900, met: false, needed: 200, open: 4, shortfall: false, ...over,
+ });
+
+ test("amount captions, counts and labels group thousands", () => {
+ expect(goalCaption(status({}))).toBe("1,900 of 2,100 this week · 200 to go");
+ expect(goalCaption(status({ count: 2100, met: true, needed: 0 }))).toBe(
+ "2,100 of 2,100 · Done this week",
+ );
+ expect(goalCount(status({}))).toBe("1,900/2,100 wk");
+ expect(goalLabel(status({}))).toBe("1,900 of 2,100 this week");
+ });
+
+ test("month scope", () => {
+ const month = status({ scope: "month", measure: "days", required: 1, count: 0, needed: 1 });
+ expect(goalCount(month)).toBe("0/1 mo");
+ expect(goalLabel(month)).toBe("0 of 1 this month");
+ expect(goalLabel({ ...month, count: 1, met: true, needed: 0 })).toBe("done this month");
+ expect(goalCaption({ ...month, count: 1, met: true, needed: 0 })).toBe(
+ "1 of 1 · Done this month",
+ );
+ });
+
+ test("goalRequired reads either measure", () => {
+ expect(goalRequired(weekly)).toBe(5);
+ expect(goalRequired({ period: "event", measure: "amount", target: 2100 })).toBe(2100);
+ });
+});
diff --git a/test/views.test.tsx b/test/views.test.tsx
index b11746b..fe2b8ff 100644
--- a/test/views.test.tsx
+++ b/test/views.test.tsx
@@ -1170,8 +1170,8 @@ describe("goals in the dailies strip", () => {
sourceUrl: "https://example.test",
} as never;
const status = (over: Partial = {}): GoalStatus => ({
- scope: "week", required: 5, count: 3, met: false, needed: 2,
- open: 4, shortfall: false, ...over,
+ scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 3,
+ met: false, needed: 2, open: 4, shortfall: false, ...over,
});
const strip = (goal: GoalStatus) =>
render(
@@ -1280,7 +1280,8 @@ describe("DailyChecklist with a goal", () => {
logged={["2026-10-12"]}
onToggleDay={() => {}}
goal={{
- scope: "week", required: 5, count: 1, met: false, needed: 4, open: 4, shortfall: false,
+ scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 1,
+ met: false, needed: 4, open: 4, shortfall: false,
}}
/>,
);