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.
240 lines
7.4 KiB
JavaScript
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 ?? ""),
|
|
};
|
|
}
|