feat(goal): let a goal be a number against a target, and a period a month

Weekly chores like 2,100 activity points are a number, not a count of
days, and monthly ones need a month. The status shape now carries its
measure and period start, so tasks can reuse it whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 13:30:47 +02:00
co-authored by Claude Opus 5.5
parent e0aca3b1fb
commit e7ee43f92d
4 changed files with 172 additions and 28 deletions
+80 -17
View File
@@ -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<typeof GoalSchema>;
/** 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<string, { value: number; at: string }>;
/**
* 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. */