feat: add an effort scale with time semantics
Four buckets named the way a player would describe them — quick, short, long, grind — each carrying rough working hours. The hours exist so effort can answer the question the calendar cannot: can I still finish this? The same two days is comfortable for a quick event and hopeless for a grind. The runway heuristic assumes about an hour of play a day, which is deliberately modest — a warning that only fires once something is already impossible is useless. An event with no recorded effort returns "fine" and never warns. Inferring an estimate in order to warn about it would be fabricating the reader's input. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
25c32dbe62
commit
ea719730a7
@@ -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<Effort, EffortMeta> = {
|
||||
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.`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user