feat(intake): size band derived from decomposition and known areas

This commit is contained in:
Dave 2026-09-16 17:54:39 +02:00
parent 95c4c6c8fa
commit 04f38ee114
2 changed files with 110 additions and 0 deletions

View file

@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { deriveSizeBand } from "@/lib/intake/size";
describe("deriveSizeBand", () => {
it("calls a short decomposition small", () => {
expect(
deriveSizeBand({ subtaskCount: 2, knownScopes: ["stock"], suggestedArea: "stock" })
).toBe("small");
});
it("calls a middling decomposition medium", () => {
expect(
deriveSizeBand({ subtaskCount: 5, knownScopes: ["stock"], suggestedArea: "stock" })
).toBe("medium");
});
it("calls a long decomposition large", () => {
expect(
deriveSizeBand({ subtaskCount: 9, knownScopes: ["stock"], suggestedArea: "stock" })
).toBe("large");
});
it("raises the band when the area is new to the project", () => {
// Work in an area with no commit history is less well understood than the
// same-sized work somewhere the project has been before.
expect(
deriveSizeBand({ subtaskCount: 2, knownScopes: ["stock"], suggestedArea: "pos" })
).toBe("medium");
});
it("does not raise past large", () => {
expect(
deriveSizeBand({ subtaskCount: 12, knownScopes: ["stock"], suggestedArea: "pos" })
).toBe("large");
});
it("treats an unknown area as new", () => {
expect(
deriveSizeBand({ subtaskCount: 2, knownScopes: [], suggestedArea: "" })
).toBe("medium");
});
it("matches the area case-insensitively", () => {
expect(
deriveSizeBand({ subtaskCount: 2, knownScopes: ["Stock"], suggestedArea: "stock" })
).toBe("small");
});
it("never returns an empty band for a real brief", () => {
for (const count of [0, 1, 3, 6, 20]) {
const band = deriveSizeBand({ subtaskCount: count, knownScopes: [], suggestedArea: "" });
expect(["small", "medium", "large"]).toContain(band);
}
});
it("does not crash on a negative or absurd count", () => {
expect(deriveSizeBand({ subtaskCount: -5, knownScopes: ["a"], suggestedArea: "a" })).toBe("small");
expect(deriveSizeBand({ subtaskCount: 10_000, knownScopes: ["a"], suggestedArea: "a" })).toBe("large");
});
});

50
src/lib/intake/size.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* A rough size band for a request. Small, medium or large — never a duration.
*
* Derived rather than guessed, from two things the system actually knows:
*
* - How many sub-tasks the request decomposed into. A request that becomes two
* steps is not the same as one that becomes eleven.
* - Whether it lands in an area the project already has commit history in.
* Phase 2 knows every conventional-commit scope and how much work each holds.
*
* Stated plainly because it is the weakest thing in this phase: this is a
* judgement made from a description collected minutes ago. Better than nothing,
* worse than the developer's opinion, and the UI must never imply otherwise —
* it is shown as a first impression and the developer sets the real one when
* accepting from the Inbox.
*
* NEVER render this next to a date or a duration. The spec forbids attaching
* time to it, and a band beside a date reads as an estimate no matter how it is
* labelled.
*/
export type SizeBand = "small" | "medium" | "large";
const ORDER: SizeBand[] = ["small", "medium", "large"];
export function deriveSizeBand(input: {
subtaskCount: number;
/** Conventional-commit scopes this project already has history in. */
knownScopes: string[];
/** The area the brief suggests, or "". */
suggestedArea: string;
}): SizeBand {
const count = Number.isFinite(input.subtaskCount)
? Math.max(0, input.subtaskCount)
: 0;
let index = count <= 3 ? 0 : count <= 7 ? 1 : 2;
const area = (input.suggestedArea ?? "").trim().toLowerCase();
const known = new Set(
(input.knownScopes ?? []).map((scope) => scope.trim().toLowerCase())
);
// New territory: the same amount of visible work carries more unknowns.
if (!area || !known.has(area)) {
index = Math.min(index + 1, ORDER.length - 1);
}
return ORDER[index];
}