Files
snowspeeder 59645e59b5 Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from
hope_roaster: lot tracking with audit-logged consumption, cupping
sessions with server-authoritative scoring and a live radar chart,
and links from the planner (draw-from-lot, open-cupping-session).

Also fixes the Blend/Single-origin toggle layout, replaces tooltips
with an in-context "why" teaching layer, and stages the planner UI
into pre-roast vs. post-roast phases.
2026-07-30 14:47:15 -04:00

240 lines
7.4 KiB
JavaScript

// Browser-safe ESM (served at /shared/, imported by both server and client — no Node-only
// APIs) — the single implementation of cupping scoring/validation. The server always
// recomputes the total from coerceSession's output; a client-submitted total is never trusted.
//
// FLAVOR_TAXONOMY is an ORIGINAL taxonomy for this project: the family/subgroup/descriptor
// list and id strings are our own, deliberately not a transcription of the copyrighted
// SCA/WCR Coffee Taster's Flavor Wheel graphic or its exact category boundaries/wording.
export const SCORE_ATTRS = [
"fragrance_aroma",
"flavor",
"aftertaste",
"acidity",
"body",
"balance",
"overall",
];
export const SCORE_LABELS = {
fragrance_aroma: "Fragrance/Aroma",
flavor: "Flavor",
aftertaste: "Aftertaste",
acidity: "Acidity",
body: "Body",
balance: "Balance",
overall: "Overall",
};
export const TICK_ATTRS = ["uniformity", "clean_cup", "sweetness"];
export const TICK_LABELS = {
uniformity: "Uniformity",
clean_cup: "Clean cup",
sweetness: "Sweetness",
};
export const STAGE_IDS = [
"dry_fragrance",
"pour",
"crust_aroma",
"break",
"skim",
"taste_1",
"taste_2",
"taste_3",
];
const STAGE_ORDER = Object.fromEntries(STAGE_IDS.map((s, i) => [s, i]));
export const DEFAULT_CUP_COUNT = 5;
export const MAX_CUP_COUNT = 12;
export const MAX_FLAVOR_TAGS = 32;
export const MAX_NOTES_CHARS = 4000;
const MAX_STAGE_ELAPSED_SEC = 24 * 60 * 60;
// 1-3 dot-separated segments, lowercase alnum(+underscore) in EVERY segment — three family
// ids (nutty_cocoa, green_vegetal, fermented_sour) have an underscore in the first segment.
const FLAVOR_ID_RE = /^[a-z0-9_]+(\.[a-z0-9_]+){0,2}$/;
export const FLAVOR_TAXONOMY = {
fruity: {
label: "Fruity",
subgroups: {
berry: ["blueberry", "blackberry", "strawberry"],
citrus: ["orange", "lemon", "grapefruit"],
stone_fruit: ["peach", "apricot", "cherry"],
dried_fruit: ["raisin", "fig", "prune"],
},
},
floral: {
label: "Floral",
subgroups: {
blossom: ["jasmine", "orange_blossom", "chamomile"],
herbal_floral: ["lavender", "rose"],
},
},
sweet: {
label: "Sweet",
subgroups: {
sugars: ["brown_sugar", "honey", "caramel"],
vanilla: ["vanilla", "malt"],
},
},
nutty_cocoa: {
label: "Nutty / Cocoa",
subgroups: {
nutty: ["almond", "hazelnut", "peanut"],
cocoa: ["dark_chocolate", "cocoa_powder"],
},
},
spice: {
label: "Spice",
subgroups: {
warm_spice: ["cinnamon", "clove", "nutmeg"],
pungent: ["pepper", "anise"],
},
},
roasted: {
label: "Roasted",
subgroups: {
grain: ["toast", "cereal"],
char: ["smoky", "tobacco", "pipe_tobacco"],
},
},
green_vegetal: {
label: "Green / Vegetal",
subgroups: {
fresh: ["cut_grass", "leafy", "herbaceous"],
raw: ["beany", "peapod"],
},
},
fermented_sour: {
label: "Fermented / Sour",
subgroups: {
sour: ["citric", "acetic", "tart"],
fermented: ["winey", "boozy", "overripe"],
},
},
other: {
label: "Other",
subgroups: {
chemical: ["rubber", "medicinal"],
papery: ["papery", "musty", "woody"],
},
},
};
const snapQuarter = (v) => Math.round(v * 4) / 4;
export function blankSession(cupCount = DEFAULT_CUP_COUNT) {
return {
cup_count: cupCount,
scores: Object.fromEntries(SCORE_ATTRS.map((a) => [a, 0])),
ticks: Object.fromEntries(TICK_ATTRS.map((a) => [a, 0])),
taint_cups: 0,
fault_cups: 0,
flavor_tags: [],
stage_marks: [],
ritual_started_at_iso: "",
notes: "",
};
}
/** total = sum(scored attrs) + 10*ticks/cupCount per tick attr - 2*taint - 4*fault, floored
* at 0, rounded to 2dp. Pure arithmetic over whatever it's handed — call coerceSession first. */
export function computeTotalScore(scores, ticks, taintCups, faultCups, cupCount) {
let total = SCORE_ATTRS.reduce((sum, a) => sum + Number(scores?.[a] ?? 0), 0);
const cc = Number(cupCount);
if (cc > 0)
total += TICK_ATTRS.reduce((sum, a) => sum + (10 * Number(ticks?.[a] ?? 0)) / cc, 0);
total -= 2 * Number(taintCups ?? 0);
total -= 4 * Number(faultCups ?? 0);
total = Math.max(0, total);
return Math.round(total * 100) / 100;
}
/** Validates and clamps a session document. Throws Error with a human message on genuinely
* bad input (malformed flavor id, unknown stage, too many tags); everything else is clamped. */
export function coerceSession(session) {
const raw = session && typeof session === "object" ? session : {};
// Tolerant of a missing cup_count (defaults it), same stance as every other field below —
// but a *present and garbage* value (NaN, a string, etc.) is a genuine client bug, not an
// absent field, so that still throws rather than silently defaulting.
const cupCountRaw = raw.cup_count == null ? DEFAULT_CUP_COUNT : Number(raw.cup_count);
if (!Number.isFinite(cupCountRaw)) throw new Error("cup_count must be a finite number");
const cup_count = Math.max(1, Math.min(MAX_CUP_COUNT, Math.round(cupCountRaw)));
const scores = {};
for (const attr of SCORE_ATTRS) {
const f = Number(raw.scores?.[attr] ?? 0);
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
scores[attr] = f <= 0 ? 0 : Math.round(snapQuarter(Math.max(6, Math.min(10, f))) * 100) / 100;
}
const ticks = {};
for (const attr of TICK_ATTRS) {
const f = Number(raw.ticks?.[attr] ?? 0);
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
ticks[attr] = Math.max(0, Math.min(cup_count, Math.round(f)));
}
const taintRaw = Number(raw.taint_cups ?? 0);
if (!Number.isFinite(taintRaw)) throw new Error("taint_cups must be a finite number");
const taint_cups = Math.max(0, Math.min(cup_count, Math.round(taintRaw)));
const faultRaw = Number(raw.fault_cups ?? 0);
if (!Number.isFinite(faultRaw)) throw new Error("fault_cups must be a finite number");
const fault_cups = Math.max(0, Math.min(cup_count, Math.round(faultRaw)));
const notes = String(raw.notes ?? "").slice(0, MAX_NOTES_CHARS);
const flavor_tags = [];
const seen = new Set();
for (const tag of Array.isArray(raw.flavor_tags) ? raw.flavor_tags : []) {
if (typeof tag !== "string" || !FLAVOR_ID_RE.test(tag))
throw new Error(`invalid flavor tag id: ${JSON.stringify(tag)}`);
if (seen.has(tag)) continue;
seen.add(tag);
flavor_tags.push(tag);
}
if (flavor_tags.length > MAX_FLAVOR_TAGS)
throw new Error(
`at most ${MAX_FLAVOR_TAGS} flavor tags allowed, got ${flavor_tags.length} (after de-duplication)`,
);
const markByStage = {};
for (const mark of Array.isArray(raw.stage_marks) ? raw.stage_marks : []) {
const stage = mark?.stage;
// Object.hasOwn, not `in` — `in` walks the prototype chain, so a stage value of
// "toString"/"constructor"/etc. would pass validation and later corrupt the sort below
// (STAGE_ORDER["toString"] is a function, not a number).
if (!Object.hasOwn(STAGE_ORDER, stage))
throw new Error(`unknown cupping stage: ${JSON.stringify(stage)}`);
const elapsedRaw = Number(mark.elapsed_sec);
if (!Number.isFinite(elapsedRaw)) throw new Error("elapsed_sec must be a finite number");
const elapsed_sec = Math.max(0, Math.min(MAX_STAGE_ELAPSED_SEC, elapsedRaw));
markByStage[stage] = {
stage,
elapsed_sec,
marked_at_iso: String(mark.marked_at_iso ?? ""),
};
}
const stage_marks = Object.values(markByStage).sort(
(a, b) => STAGE_ORDER[a.stage] - STAGE_ORDER[b.stage],
);
return {
cup_count,
scores,
ticks,
taint_cups,
fault_cups,
notes,
flavor_tags,
stage_marks,
ritual_started_at_iso: String(raw.ritual_started_at_iso ?? ""),
};
}