diff --git a/src/shared/effort.ts b/src/shared/effort.ts new file mode 100644 index 0000000..277c1f7 --- /dev/null +++ b/src/shared/effort.ts @@ -0,0 +1,79 @@ +import { DAY, HOUR } from "./time.ts"; + +/** + * How much work the player reckons an event will take. + * + * Four buckets, named the way a player would describe them rather than in + * hours — nobody knows an event will take "3.5 hours", but everyone knows the + * difference between a login page and a grind. + */ +export const EFFORTS = ["quick", "short", "long", "grind"] as const; +export type Effort = (typeof EFFORTS)[number]; + +export interface EffortMeta { + id: Effort; + label: string; + /** What the bucket means, in the player's terms. */ + hint: string; + /** Rough working time, used only for the "will you finish?" heuristic. */ + hours: number; +} + +export const EFFORT: Record = { + quick: { id: "quick", label: "Quick", hint: "a few minutes", hours: 0.25 }, + short: { id: "short", label: "Short", hint: "under an hour", hours: 1 }, + long: { id: "long", label: "Long", hint: "a few hours", hours: 4 }, + grind: { id: "grind", label: "Grind", hint: "several sessions", hours: 12 }, +}; + +export const EFFORT_LIST: EffortMeta[] = EFFORTS.map((e) => EFFORT[e]); + +/** + * Assumed play time per day when working out whether an event is still + * finishable. One hour is a deliberately modest guess: the point is to warn + * early, and a warning that only fires when it is already impossible is + * useless. + * + * This is a heuristic and the UI says so. It is never used to hide or reorder + * anything — only to add a flag the reader can ignore. + */ +export const PLAY_HOURS_PER_DAY = 1; + +/** Time you'd want left over to comfortably finish an event of this effort. */ +export function runwayMs(effort: Effort): number { + return (EFFORT[effort].hours / PLAY_HOURS_PER_DAY) * DAY; +} + +export type Pressure = "fine" | "tight" | "unlikely"; + +/** + * Whether the remaining time still covers the declared effort. + * + * Returns "fine" when there is no effort recorded — an unestimated event is + * not a warning, and guessing an estimate for the reader would be inventing + * information they did not give. + */ +export function pressure( + effort: Effort | undefined, + msRemaining: number | null, +): Pressure { + if (effort === undefined || msRemaining === null) return "fine"; + if (msRemaining <= 0) return "unlikely"; + + const needed = runwayMs(effort); + if (msRemaining >= needed) return "fine"; + // Below a quarter of the runway, calling it "tight" would be optimistic. + if (msRemaining >= needed / 4) return "tight"; + return "unlikely"; +} + +/** Plain-language reason, for a tooltip or the detail sheet. */ +export function pressureReason( + effort: Effort, + msRemaining: number, +): string { + const hours = EFFORT[effort].hours; + const left = Math.max(0, Math.round(msRemaining / HOUR)); + const days = Math.max(1, Math.round(runwayMs(effort) / DAY)); + return `You marked this as ${EFFORT[effort].label.toLowerCase()} (${hours}h of play). At about ${PLAY_HOURS_PER_DAY}h a day that wants ~${days} day${days > 1 ? "s" : ""}, and ${left}h remain.`; +} diff --git a/test/effort.test.ts b/test/effort.test.ts new file mode 100644 index 0000000..25239ee --- /dev/null +++ b/test/effort.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { + EFFORT, + pressure, + pressureReason, + runwayMs, + type Effort, +} from "../src/shared/effort.ts"; +import { DAY, HOUR } from "../src/shared/time.ts"; + +describe("runwayMs", () => { + test("scales with the bucket's working hours", () => { + // At one play-hour a day, a 12-hour grind wants roughly twelve days. + expect(runwayMs("quick")).toBeLessThan(runwayMs("short")); + expect(runwayMs("short")).toBeLessThan(runwayMs("long")); + expect(runwayMs("grind")).toBe(EFFORT.grind.hours * DAY); + }); +}); + +describe("pressure", () => { + test("says nothing when the reader gave no estimate", () => { + // A warning needs an estimate to rest on, and inventing one to justify the + // warning would be fabricating the reader's own input. + expect(pressure(undefined, 2 * HOUR)).toBe("fine"); + }); + + test("says nothing when the end is unannounced", () => { + expect(pressure("grind", null)).toBe("fine"); + }); + + test("is fine when the runway fits", () => { + expect(pressure("quick", 5 * DAY)).toBe("fine"); + expect(pressure("grind", 30 * DAY)).toBe("fine"); + }); + + test("tightens as the deadline closes on a big job", () => { + // A grind wants ~12 days; 6 left is tight, 2 is not realistic. + expect(pressure("grind", 6 * DAY)).toBe("tight"); + expect(pressure("grind", 2 * DAY)).toBe("unlikely"); + }); + + test("treats the same deadline differently by effort", () => { + // This is the whole point of recording effort: two days is comfortable for + // a quick event and hopeless for a grind. + const twoDays = 2 * DAY; + expect(pressure("quick", twoDays)).toBe("fine"); + expect(pressure("short", twoDays)).toBe("fine"); + expect(pressure("grind", twoDays)).toBe("unlikely"); + }); + + test("an ended event is never merely tight", () => { + expect(pressure("quick", 0)).toBe("unlikely"); + expect(pressure("quick", -HOUR)).toBe("unlikely"); + }); +}); + +describe("pressureReason", () => { + test("shows its working rather than just asserting", () => { + const reason = pressureReason("grind", 48 * HOUR); + expect(reason).toContain("12h of play"); + expect(reason).toContain("48h remain"); + }); + + test("covers every bucket without throwing", () => { + for (const e of Object.keys(EFFORT) as Effort[]) { + expect(pressureReason(e, 6 * HOUR).length).toBeGreaterThan(20); + } + }); +});