Speak the scorecard's own band labels, not a second vocabulary
The Trainability and Deal Qualification Scorecard publishes five bands and an action for each — Decline, Defer, Scope down, Qualified conditional, Build — and `MOTION_BANDS` published four different ones with different edges. So a reader could read the scorecard, score a deal against the exact dimensions it defines, and be told "Strategic" by a band table that document has never heard of. Two answers to the same question from the same product. The scorecard wins, on two grounds. Its edges were chosen alongside the dimension weights they sit on top of, so 78 means something there and 7500 was a round number here. And every one of its labels is a verb the reader can act on: "Qualified" describes a deal, "Scope down" says what to do about it, which is the only reason to band a score rather than show it. The labels are now duplicated between the JSON a customer reads and the table the product renders, because a rendered label cannot reach into a seeded row. That duplication gets a test asserting the whole table verbatim, so re-authoring one copy alone fails rather than drifts. `apps/api/test/motion.test.ts` asserted the literal 'Strategic'. It now derives the band through the shared function, so a band-table change is caught by the test that owns the decision instead of by a write-path test that does not.
This commit is contained in:
@@ -200,19 +200,34 @@ export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore
|
||||
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
||||
}
|
||||
|
||||
/*
|
||||
* The band labels are the SCORECARD'S OWN, not a second vocabulary invented
|
||||
* here. `seed/motion/trainability-qualification.json` publishes five bands and
|
||||
* an action for each, and a reader who has just read that document and then
|
||||
* sees the score rendered as "Strategic" has been told two different things
|
||||
* about the same number by the same product. Every label below is a verb the
|
||||
* reader can act on, which is the point of a band at all: "Qualified" describes
|
||||
* the deal, "Scope down" says what to do about it.
|
||||
*
|
||||
* Boundaries are the authored percentages in basis points. If the scorecard's
|
||||
* bands are ever re-authored, these move with them — they are one decision
|
||||
* recorded twice, and the JSON is the copy a customer reads.
|
||||
*/
|
||||
export const MOTION_BANDS = [
|
||||
{ min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' },
|
||||
{ min: 3500, max: 5499, label: 'Not yet', tone: 'warning' },
|
||||
{ min: 5500, max: 7499, label: 'Qualified', tone: 'info' },
|
||||
{ min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' },
|
||||
{ min: 0, max: 2999, label: 'Decline', tone: 'danger' },
|
||||
{ min: 3000, max: 4599, label: 'Defer', tone: 'danger' },
|
||||
{ min: 4600, max: 6199, label: 'Scope down', tone: 'warning' },
|
||||
{ min: 6200, max: 7799, label: 'Qualified, conditional', tone: 'info' },
|
||||
{ min: 7800, max: 10_000, label: 'Build', tone: 'positive' },
|
||||
] as const;
|
||||
export type MotionBand = (typeof MOTION_BANDS)[number];
|
||||
export type MotionBandTone = MotionBand['tone'];
|
||||
|
||||
/**
|
||||
* The band a score falls in. Boundaries are inclusive at `min`, so 3500 is
|
||||
* "Not yet" and 3499 is not — an off-by-one here changes what a seller is told
|
||||
* to do without changing any number they can see.
|
||||
* The band a score falls in. Boundaries are inclusive at `min`, so 6200 is
|
||||
* "Qualified, conditional" and 6199 is "Scope down" — an off-by-one here
|
||||
* changes what a seller is told to do without changing any number they can
|
||||
* see.
|
||||
*
|
||||
* Out-of-range input clamps to an end band rather than returning undefined,
|
||||
* because every caller renders this and none of them has a null branch.
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('motionScoreBasisPoints', () => {
|
||||
|
||||
it('returns 0 rather than throwing when every weight is zero', () => {
|
||||
// The naive version divides by the weight total and produces Infinity,
|
||||
// which `motionBand` would then clamp to "Strategic" — a deal nobody
|
||||
// which `motionBand` would then clamp to "Build" — a deal nobody
|
||||
// scored recommended as the best one in the book.
|
||||
const score = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 0, score: 4 },
|
||||
@@ -112,20 +112,41 @@ describe('motionBand', () => {
|
||||
* file and tells a seller to defer a deal that qualified.
|
||||
*/
|
||||
it('is inclusive at the lower edge of every band', () => {
|
||||
assert.equal(motionBand(3500).label, 'Not yet');
|
||||
assert.equal(motionBand(5500).label, 'Qualified');
|
||||
assert.equal(motionBand(7500).label, 'Strategic');
|
||||
assert.equal(motionBand(3000).label, 'Defer');
|
||||
assert.equal(motionBand(4600).label, 'Scope down');
|
||||
assert.equal(motionBand(6200).label, 'Qualified, conditional');
|
||||
assert.equal(motionBand(7800).label, 'Build');
|
||||
});
|
||||
|
||||
it('keeps the basis point below each edge in the band underneath', () => {
|
||||
assert.equal(motionBand(3499).label, 'Decline or defer');
|
||||
assert.equal(motionBand(5499).label, 'Not yet');
|
||||
assert.equal(motionBand(7499).label, 'Qualified');
|
||||
assert.equal(motionBand(2999).label, 'Decline');
|
||||
assert.equal(motionBand(4599).label, 'Defer');
|
||||
assert.equal(motionBand(6199).label, 'Scope down');
|
||||
assert.equal(motionBand(7799).label, 'Qualified, conditional');
|
||||
});
|
||||
|
||||
it('covers both ends of the scale', () => {
|
||||
assert.equal(motionBand(0).label, 'Decline or defer');
|
||||
assert.equal(motionBand(10_000).label, 'Strategic');
|
||||
assert.equal(motionBand(0).label, 'Decline');
|
||||
assert.equal(motionBand(10_000).label, 'Build');
|
||||
});
|
||||
|
||||
/**
|
||||
* The scorecard a customer reads publishes these five bands and an action for
|
||||
* each. They are duplicated in `MOTION_BANDS` because the product renders a
|
||||
* label the JSON cannot reach — so this pins the two copies together, and
|
||||
* fails when somebody re-authors one of them alone.
|
||||
*/
|
||||
it('uses the labels and edges the seeded scorecard publishes', () => {
|
||||
assert.deepEqual(
|
||||
MOTION_BANDS.map((band) => [band.min, band.max, band.label]),
|
||||
[
|
||||
[0, 2999, 'Decline'],
|
||||
[3000, 4599, 'Defer'],
|
||||
[4600, 6199, 'Scope down'],
|
||||
[6200, 7799, 'Qualified, conditional'],
|
||||
[7800, 10_000, 'Build'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves no gap and no overlap across the whole range', () => {
|
||||
|
||||
Reference in New Issue
Block a user