From 9e826f22e41a87da5b27cc8c879fe3920133604c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 13:05:36 +0200 Subject: [PATCH] feat(goal): count check-ins against a weekly or event-wide target Some events want five days out of seven, not every day. The goal is the reader's input, recomputed from the day log on every read, so backfills and week rollovers need no bookkeeping. An unreadable stored goal reads as none instead of being stripped, so a newer shape survives an older bundle. Co-Authored-By: Claude Opus 5.5 --- src/shared/goal.ts | 155 ++++++++++++++++++++++++++++++++++ test/goal.test.ts | 203 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 src/shared/goal.ts create mode 100644 test/goal.test.ts diff --git a/src/shared/goal.ts b/src/shared/goal.ts new file mode 100644 index 0000000..c3ef09e --- /dev/null +++ b/src/shared/goal.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import type { LaneId } from "./custom.ts"; +import { dailyDays, dayKey } from "./daily.ts"; +import { addDays, periodOf, WeekdaySchema } from "./period.ts"; +import type { Region } from "./schema.ts"; + +/** + * A check-in goal: the event wants N distinct game-days, per week or across its + * whole run, rather than every day. + * + * Always the reader's input — nothing here reads a title or a source. Meeting + * a goal never finishes the event: reaching a count may not have claimed the + * rewards, so only the reader marks an event done. + * + * A plain union rather than discriminatedUnion("period"): weekly-tasks work + * adds amount variants sharing a period with these, so no one field + * discriminates. + */ +export const GoalSchema = z.union([ + z.object({ + period: z.literal("week"), + measure: z.literal("days"), + required: z.number().int().min(1).max(7), + weekStartsOn: WeekdaySchema, + }), + z.object({ + period: z.literal("event"), + measure: z.literal("days"), + required: z.number().int().min(1).max(366), + }), +]); +export type Goal = z.infer; + +/** + * A stored goal, or undefined when absent or unreadable. + * + * **Validated here, never stripped from storage.** Dropping an unreadable field + * on load would delete it on the next write, and the likeliest unreadable goal + * is a newer shape read by an older cached bundle — see AGENTS.md § Events the + * reader entered themselves. + */ +export function readGoal(stored: unknown): Goal | undefined { + const parsed = GoalSchema.safeParse(stored); + return parsed.success ? parsed.data : undefined; +} + +export interface GoalStatus { + scope: "week" | "event"; + required: number; + /** Distinct ticked game-days in scope, never later than today. */ + count: number; + met: boolean; + needed: number; + /** Unticked days in scope from today on; null when the end is unannounced. */ + open: number | null; + /** Fewer days left than check-ins needed. The target is never lowered. */ + shortfall: boolean; +} + +/** Bounds a walk over keys when the end is unknown; matches daily.ts. */ +const MAX_DAYS = 200; + +function keysFrom(first: string, last: string): string[] { + const out: string[] = []; + for (let k = first; k <= last && out.length < MAX_DAYS; k = addDays(k, 1)) out.push(k); + return out; +} + +/** + * Where the reader is against their goal, recomputed from the day log on every + * call — so a backfilled or removed tick needs no bookkeeping, and a week + * rolling over clears nothing. + */ +export function goalStatus(input: { + goal: Goal; + startsMs: number; + endsMs: number | null; + region: Region; + game: LaneId; + now: number; + logged: readonly string[]; +}): GoalStatus { + const { goal, startsMs, endsMs, region, game, now, logged } = input; + const today = dayKey(now, region, game); + const first = dayKey(startsMs, region, game); + const run = dailyDays(startsMs, endsMs, region, game); + + let scope: 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)); + } else { + scope = run ?? keysFrom(first, today); + } + + 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 needed = Math.max(0, goal.required - count); + + return { + scope: goal.period, + required: goal.required, + count, + met: count >= goal.required, + needed, + open, + shortfall: open !== null && needed > open, + }; +} + +const days = (n: number) => (n === 1 ? "1 day" : `${n} days`); + +/** + * 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" : ""; + 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}`; +} + +/** The Dailies chip's short count. */ +export function goalCount(s: GoalStatus): string { + return `${s.count}/${s.required}${s.scope === "week" ? " wk" : ""}`; +} + +/** 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; +} + +/** Keeps an edited count inside what the goal can mean. */ +export function clampRequired( + period: "week" | "event", + n: number, + maxDays: number | null, +): number { + const max = period === "week" ? 7 : (maxDays ?? 366); + if (!Number.isFinite(n)) return 1; + return Math.min(max, Math.max(1, Math.round(n))); +} diff --git a/test/goal.test.ts b/test/goal.test.ts new file mode 100644 index 0000000..885de88 --- /dev/null +++ b/test/goal.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { + clampRequired, + goalCaption, + goalCount, + goalLabel, + goalStatus, + readGoal, + type Goal, + type GoalStatus, +} from "../src/shared/goal.ts"; + +/** + * Check-in goals: N distinct game-days per week or across an event, rather than + * every day. The owner's case is Genshin's “To Temper Thyself and Journey + * Far” — five of seven, weeks from Monday — and these dates are that event's. + */ + +// Genshin, Europe: the day rolls at 03:00Z. Noon is safely inside a game-day. +const noon = (key: string) => Date.parse(`${key}T12:00:00Z`); +const START = Date.parse("2026-08-10T03:00:00Z"); // Monday's reset +const END = Date.parse("2026-11-02T03:00:00Z"); // twelve weeks later +const weekly: Goal = { period: "week", measure: "days", required: 5, weekStartsOn: 1 }; +const base = { startsMs: START, endsMs: END, region: "europe" as const, game: "genshin" }; + +describe("readGoal", () => { + test("reads a valid goal", () => { + expect(readGoal(weekly)).toEqual(weekly); + }); + + 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(); + expect(readGoal("five")).toBeUndefined(); + }); +}); + +describe("goalStatus: each week", () => { + test("three distinct ticks this week read 3 of 5", () => { + const s = goalStatus({ + ...base, goal: weekly, now: noon("2026-10-15"), + logged: ["2026-10-12", "2026-10-13", "2026-10-15"], + }); + expect(s).toMatchObject({ scope: "week", count: 3, required: 5, met: false, needed: 2 }); + // Today (Thu) is ticked; Fri, Sat and Sun remain. + expect(s.open).toBe(3); + expect(s.shortfall).toBe(false); + }); + + test("five non-consecutive days meet the week", () => { + const s = goalStatus({ + ...base, goal: weekly, now: noon("2026-10-18"), + logged: ["2026-10-12", "2026-10-14", "2026-10-15", "2026-10-17", "2026-10-18"], + }); + expect(s.met).toBe(true); + expect(s.needed).toBe(0); + }); + + test("last week's ticks and backfills never count toward this week", () => { + const s = goalStatus({ + ...base, goal: weekly, now: noon("2026-10-19"), + logged: ["2026-10-12", "2026-10-14", "2026-10-15", "2026-10-17", "2026-10-18"], + }); + expect(s.count).toBe(0); + expect(s.met).toBe(false); + }); + + test("a future tick never counts", () => { + const s = goalStatus({ + ...base, goal: weekly, now: noon("2026-10-13"), + logged: ["2026-10-12", "2026-10-16"], + }); + expect(s.count).toBe(1); + }); + + test("removing a tick below target unmeets the week", () => { + const five = ["2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16"]; + const now = noon("2026-10-16"); + expect(goalStatus({ ...base, goal: weekly, now, logged: five }).met).toBe(true); + expect(goalStatus({ ...base, goal: weekly, now, logged: five.slice(1) }).met).toBe(false); + }); + + test("a short last week keeps its target and reports the shortfall", () => { + // An event ending Thursday 29 Oct at reset: its last week is Mon–Wed. + const s = goalStatus({ + ...base, endsMs: Date.parse("2026-10-29T03:00:00Z"), goal: weekly, + now: noon("2026-10-27"), logged: ["2026-10-26"], + }); + expect(s.required).toBe(5); + expect(s.needed).toBe(4); + expect(s.open).toBe(2); // Tue 27 and Wed 28 + expect(s.shortfall).toBe(true); + }); + + test("the first week is cut at the event start, not padded", () => { + // An event opening on Thursday: Mon–Wed were never claimable. + const s = goalStatus({ + ...base, startsMs: Date.parse("2026-10-15T03:00:00Z"), goal: weekly, + now: noon("2026-10-15"), logged: ["2026-10-12", "2026-10-15"], + }); + expect(s.count).toBe(1); // the Monday tick predates the event + expect(s.open).toBe(3); // Fri, Sat, Sun + expect(s.shortfall).toBe(true); + }); + + test("an unknown end still has this week's boundary", () => { + const s = goalStatus({ + ...base, endsMs: null, goal: weekly, now: noon("2026-10-15"), logged: [], + }); + expect(s.open).toBe(4); + }); + + test("before the event starts, nothing counts and nothing is negative", () => { + const s = goalStatus({ + ...base, goal: weekly, now: noon("2026-08-05"), logged: ["2026-08-05"], + }); + expect(s.count).toBe(0); + expect(s.needed).toBe(5); + }); +}); + +describe("goalStatus: across the event", () => { + const seven = { + ...base, + startsMs: Date.parse("2026-10-12T03:00:00Z"), + endsMs: Date.parse("2026-10-19T03:00:00Z"), + }; + const total: Goal = { period: "event", measure: "days", required: 5 }; + + test("reaches its target on the fifth recorded day", () => { + const logged = ["2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15"]; + expect(goalStatus({ ...seven, goal: total, now: noon("2026-10-15"), logged }).met).toBe( + false, + ); + const s = goalStatus({ + ...seven, goal: total, now: noon("2026-10-16"), logged: [...logged, "2026-10-16"], + }); + expect(s).toMatchObject({ scope: "event", count: 5, met: true }); + }); + + test("an unknown end counts ticks but cannot say how many days are left", () => { + const s = goalStatus({ + ...seven, endsMs: null, goal: total, now: noon("2026-10-14"), + logged: ["2026-10-12", "2026-10-14", "2026-10-20"], + }); + expect(s.count).toBe(2); + expect(s.open).toBeNull(); + expect(s.shortfall).toBe(false); + }); + + test("a tick outside the window does not count", () => { + const s = goalStatus({ + ...seven, goal: total, now: noon("2026-10-14"), logged: ["2026-10-11", "2026-10-13"], + }); + expect(s.count).toBe(1); + }); +}); + +describe("copy", () => { + const status = (over: Partial): GoalStatus => ({ + scope: "week", required: 5, count: 3, met: false, needed: 2, + open: 4, shortfall: false, ...over, + }); + + test("captions", () => { + expect(goalCaption(status({}))).toBe("3 of 5 this week · 2 needed · 4 days left this week"); + expect(goalCaption(status({ open: 1, shortfall: true }))).toBe( + "3 of 5 this week · 2 needed, 1 day left this week", + ); + expect(goalCaption(status({ count: 5, met: true, needed: 0 }))).toBe( + "5 of 5 · Done this week", + ); + expect(goalCaption(status({ scope: "event", open: 9 }))).toBe( + "3 of 5 · 2 needed · 9 days left", + ); + expect(goalCaption(status({ scope: "event", open: null }))).toBe("3 of 5 · 2 needed"); + expect(goalCaption(status({ scope: "event", count: 5, met: true, needed: 0 }))).toBe( + "5 of 5 · Goal reached", + ); + }); + + test("chip count and spoken state", () => { + expect(goalCount(status({}))).toBe("3/5 wk"); + expect(goalCount(status({ scope: "event" }))).toBe("3/5"); + expect(goalLabel(status({}))).toBe("3 of 5 this week"); + expect(goalLabel(status({ met: true }))).toBe("done this week"); + expect(goalLabel(status({ scope: "event", met: true }))).toBe("goal reached"); + expect(goalLabel(status({ scope: "event" }))).toBe("3 of 5"); + }); +}); + +describe("clampRequired", () => { + test("weekly is 1–7; event-wide is capped by the known days", () => { + expect(clampRequired("week", 9, null)).toBe(7); + expect(clampRequired("week", 0, null)).toBe(1); + expect(clampRequired("event", 30, 14)).toBe(14); + expect(clampRequired("event", 30, null)).toBe(30); + expect(clampRequired("event", Number.NaN, 14)).toBe(1); + }); +});