// 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 ?? ""), }; }