From 9c2d63b73bfbc829730abe481909fc4e618e00ce Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 13:32:13 +0200 Subject: [PATCH] feat(task): model the reader's own weekly and monthly tasks A task is a period that rolls on the game's reset and a measure that fills it: days ticked, or a number against a target. Its status is the goal status shape, so the dailies strip treats it like any other item. Its ids get their own reserved space, as do the built-ins to come. Co-Authored-By: Claude Opus 5.5 --- src/shared/custom.ts | 16 ++++-- src/shared/task.ts | 121 +++++++++++++++++++++++++++++++++++++++++ test/task.test.ts | 126 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 src/shared/task.ts create mode 100644 test/task.test.ts diff --git a/src/shared/custom.ts b/src/shared/custom.ts index 90fce2d..57ae1f2 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -26,11 +26,19 @@ import { EventType, GachaEvent, Precision, slugify } from "./schema.ts"; * * Every id in this app is colon-separated and the first segment decides which * space it belongs to: `dailies:` is a standing chore, `mygame:` and - * `myevent:` are the reader's own, and anything else is `::` - * from a source. The day one of these becomes a game id is the day two key - * spaces merge silently, so a test pins it against `GameId.options`. + * `myevent:` are the reader's own, `mytask:` is a weekly or monthly task they + * set up (task.ts), `routine:` is held for the built-in ones, and anything else + * is `::` from a source. The day one of these becomes a game + * id is the day two key spaces merge silently, so a test pins it against + * `GameId.options`. */ -export const RESERVED_ID_SEGMENTS = ["dailies", "mygame", "myevent"] as const; +export const RESERVED_ID_SEGMENTS = [ + "dailies", + "mygame", + "myevent", + "mytask", + "routine", +] as const; const GAME_PREFIX = "mygame"; const EVENT_PREFIX = "myevent"; diff --git a/src/shared/task.ts b/src/shared/task.ts new file mode 100644 index 0000000..43c422c --- /dev/null +++ b/src/shared/task.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import type { LaneId } from "./custom.ts"; +import { dayKey } from "./daily.ts"; +import { AmountTarget, type AmountLog, type GoalStatus } from "./goal.ts"; +import { periodOf, PeriodSchema } from "./period.ts"; +import type { Region } from "./schema.ts"; + +/** + * Weekly and monthly chores the reader sets up per game — ZZZ's weekly + * activity points, Endfield's weekly routine. + * + * Not events: no window, no end, no done or ignored. A task is a period that + * rolls on the game's own reset (`periodOf`) and a measure that fills it — + * days ticked, or a number entered against a target. Its status is the same + * `GoalStatus` an event's goal produces, so the dailies strip draws, counts and + * labels both with one set of rules. + * + * Reader-authored, so its id lives in its own reserved space and its `game` is + * any lane, for the reason `CustomEvent.game` is: narrowing it to `GameId` + * would arm every future game removal to hide a reader's row. + */ + +const TASK_PREFIX = "mytask"; + +export const TaskId = z.string().regex(/^mytask:[a-z0-9]{6,32}$/); + +/** Random, like `mintCustomEventId`: renaming a task never moves its ticks or numbers. */ +export function mintTaskId(random: () => number = Math.random): string { + let token = ""; + while (token.length < 10) { + token += Math.floor(random() * 36 ** 6) + .toString(36) + .padStart(6, "0"); + } + return `${TASK_PREFIX}:${token.slice(0, 10)}`; +} + +export const MeasureSchema = z.union([ + /** N distinct game-days ticked in the period; 1 is "done this week". */ + z.object({ kind: z.literal("days"), required: z.number().int().min(1).max(31) }), + /** A number entered against a target, with an optional unit to print. */ + z.object({ + kind: z.literal("amount"), + target: AmountTarget, + unit: z.string().trim().max(24).optional(), + }), +]); +export type Measure = z.infer; + +export const TaskSchema = z.object({ + id: TaskId, + game: z.string().min(1), + title: z.string().trim().min(1).max(80), + period: PeriodSchema, + measure: MeasureSchema, + /** The built-in routine this was copied from, when it was (phase 2b). */ + from: z.string().optional(), + at: z.string(), + updatedAt: z.string(), +}); +export type Task = z.infer; + +/** Where the reader is with a task this period. Pure; `now` explicit. */ +export function taskStatus(input: { + task: Task; + region: Region; + now: number; + logged: readonly string[]; + amounts?: AmountLog | undefined; +}): GoalStatus { + const { task, region, now, logged, amounts } = input; + const today = dayKey(now, region, task.game as LaneId); + const { start, days } = periodOf(task.period, today); + const scope = task.period.unit; + const left = days.filter((k) => k >= today); + + if (task.measure.kind === "amount") { + const count = amounts?.[start]?.value ?? 0; + const required = task.measure.target; + return { + scope, measure: "amount", periodStart: start, required, count, + met: count >= required, needed: Math.max(0, required - count), + open: left.length, shortfall: false, + }; + } + + const ticked = new Set(logged); + const count = days.filter((k) => k <= today && ticked.has(k)).length; + // A count saved for a month cannot ask for a 31st day in February. + const required = Math.min(task.measure.required, days.length); + const open = left.filter((k) => !ticked.has(k)).length; + const needed = Math.max(0, required - count); + return { + scope, measure: "days", periodStart: start, required, count, + met: count >= required, needed, open, shortfall: needed > open, + }; +} + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +function ordinal(n: number): string { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`; +} + +/** One line saying when it rolls and what it wants. */ +export function taskCaption(task: Task): string { + const when = + task.period.unit === "week" + ? `every week from ${WEEKDAYS[task.period.startsOn - 1]}` + : `every month from the ${ordinal(task.period.startsOn)}`; + const m = task.measure; + const what = + m.kind === "amount" + ? `reach ${m.target.toLocaleString("en-US")}${m.unit ? ` ${m.unit}` : ""}` + : m.required === 1 + ? "tick it off" + : `${m.required} days`; + return `${when} · ${what}`; +} diff --git a/test/task.test.ts b/test/task.test.ts new file mode 100644 index 0000000..ddbc819 --- /dev/null +++ b/test/task.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { RESERVED_ID_SEGMENTS } from "../src/shared/custom.ts"; +import { nextResetMs } from "../src/shared/daily.ts"; +import { mintTaskId, TaskSchema, taskCaption, taskStatus, type Task } from "../src/shared/task.ts"; + +/** + * A reader's own weekly and monthly chores: ZZZ's weekly activity points, + * Endfield's weekly routine. Not events — no window, no end, no status — just a + * period that rolls on the game's reset and a measure that fills it. + */ + +const AT = "2026-10-01T00:00:00.000Z"; +const noon = (key: string) => Date.parse(`${key}T12:00:00Z`); +const task = (over: Partial): Task => ({ + id: "mytask:abc123def4", + game: "zzz", + title: "Activity points", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 2100, unit: "points" }, + at: AT, + updatedAt: AT, + ...over, +}); + +describe("task ids", () => { + test("live in their own reserved space", () => { + expect(mintTaskId(() => 0.5)).toMatch(/^mytask:[a-z0-9]{10}$/); + expect(RESERVED_ID_SEGMENTS).toContain("mytask"); + expect(RESERVED_ID_SEGMENTS).toContain("routine"); + }); +}); + +describe("TaskSchema", () => { + test("accepts both measures and both periods", () => { + expect(TaskSchema.safeParse(task({})).success).toBe(true); + expect( + TaskSchema.safeParse( + task({ period: { unit: "month", startsOn: 31 }, measure: { kind: "days", required: 1 } }), + ).success, + ).toBe(true); + }); + + test("refuses a blank title, a zero target and a game-less task", () => { + expect(TaskSchema.safeParse(task({ title: " " })).success).toBe(false); + expect( + TaskSchema.safeParse(task({ measure: { kind: "amount", target: 0 } })).success, + ).toBe(false); + expect(TaskSchema.safeParse(task({ game: "" })).success).toBe(false); + }); + + test("game is any lane, not just a tracked game", () => { + expect(TaskSchema.safeParse(task({ game: "mygame:limbus-company" })).success).toBe(true); + }); +}); + +describe("taskStatus", () => { + const amounts = { + "2026-10-05": { value: 2100, at: AT }, + "2026-10-12": { value: 1900, at: AT }, + }; + + test("a weekly amount reads this week's value", () => { + const s = taskStatus({ + task: task({}), region: "europe", now: noon("2026-10-15"), logged: [], amounts, + }); + expect(s).toMatchObject({ + scope: "week", measure: "amount", periodStart: "2026-10-12", + count: 1900, required: 2100, met: false, needed: 200, + }); + }); + + test("the week rolls at the game's reset in the reader's region", () => { + const reset = nextResetMs(Date.parse("2026-10-18T12:00:00Z"), "europe", "zzz"); + const before = taskStatus({ task: task({}), region: "europe", now: reset - 1, logged: [], amounts }); + const after = taskStatus({ task: task({}), region: "europe", now: reset, logged: [], amounts }); + expect(before.periodStart).toBe("2026-10-12"); + expect(after.periodStart).toBe("2026-10-19"); + expect(after.count).toBe(0); + }); + + test("a weekly tick task is done once any day this week is ticked", () => { + const t = task({ measure: { kind: "days", required: 1 } }); + const s = taskStatus({ + task: t, region: "europe", now: noon("2026-10-15"), logged: ["2026-10-13"], + }); + expect(s).toMatchObject({ measure: "days", count: 1, met: true }); + const lastWeek = taskStatus({ + task: t, region: "europe", now: noon("2026-10-15"), logged: ["2026-10-11"], + }); + expect(lastWeek.met).toBe(false); + }); + + test("a monthly task from the 31st keys a 30-day month by its last day", () => { + const t = task({ period: { unit: "month", startsOn: 31 } }); + const s = taskStatus({ + task: t, region: "europe", now: noon("2026-10-05"), logged: [], + amounts: { "2026-09-30": { value: 50, at: AT } }, + }); + expect(s).toMatchObject({ scope: "month", periodStart: "2026-09-30", count: 50 }); + }); + + test("a days count never asks for more days than the period has", () => { + const t = task({ + period: { unit: "month", startsOn: 1 }, + measure: { kind: "days", required: 31 }, + }); + const s = taskStatus({ task: t, region: "europe", now: noon("2026-02-10"), logged: [] }); + expect(s.required).toBe(28); + }); +}); + +describe("taskCaption", () => { + test("says when it rolls and what it wants", () => { + expect(taskCaption(task({}))).toBe("every week from Monday · reach 2,100 points"); + expect( + taskCaption( + task({ period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 } }), + ), + ).toBe("every month from the 1st · tick it off"); + expect( + taskCaption( + task({ period: { unit: "month", startsOn: 22 }, measure: { kind: "days", required: 3 } }), + ), + ).toBe("every month from the 22nd · 3 days"); + }); +});