From 04f38ee114d56594dd573452a3e1f8296de9ec47 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 17:54:39 +0200 Subject: [PATCH] feat(intake): size band derived from decomposition and known areas --- src/lib/intake/size.test.ts | 60 +++++++++++++++++++++++++++++++++++++ src/lib/intake/size.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/lib/intake/size.test.ts create mode 100644 src/lib/intake/size.ts diff --git a/src/lib/intake/size.test.ts b/src/lib/intake/size.test.ts new file mode 100644 index 0000000..32150a6 --- /dev/null +++ b/src/lib/intake/size.test.ts @@ -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"); + }); +}); diff --git a/src/lib/intake/size.ts b/src/lib/intake/size.ts new file mode 100644 index 0000000..2404172 --- /dev/null +++ b/src/lib/intake/size.ts @@ -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]; +}