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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7ee43f92d
commit
9c2d63b73b
3 files changed
+259
-4
No files matched your search
+12
-4
@@ -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
|
* Every id in this app is colon-separated and the first segment decides which
|
||||||
* space it belongs to: `dailies:<game>` is a standing chore, `mygame:` and
|
* space it belongs to: `dailies:<game>` is a standing chore, `mygame:` and
|
||||||
* `myevent:` are the reader's own, and anything else is `<game>:<slug>:<date>`
|
* `myevent:` are the reader's own, `mytask:` is a weekly or monthly task they
|
||||||
* from a source. The day one of these becomes a game id is the day two key
|
* set up (task.ts), `routine:` is held for the built-in ones, and anything else
|
||||||
* spaces merge silently, so a test pins it against `GameId.options`.
|
* is `<game>:<slug>:<date>` 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 GAME_PREFIX = "mygame";
|
||||||
const EVENT_PREFIX = "myevent";
|
const EVENT_PREFIX = "myevent";
|
||||||
|
|||||||
@@ -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<typeof MeasureSchema>;
|
||||||
|
|
||||||
|
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<typeof TaskSchema>;
|
||||||
|
|
||||||
|
/** 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}`;
|
||||||
|
}
|
||||||
@@ -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>): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user