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); + }); +});