setOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 9998 }} />
{METHODS.map((m) => (
{ onChange(m); setOpen(false); }} style={{
padding: '8px 12px', borderRadius: 10,
fontSize: 14, fontWeight: 500, color: PALETTE.espresso,
cursor: 'pointer',
background: m === value ? PALETTE.crema : 'transparent',
}}>{m}
))}
>,
document.body
)}
);
}
function TastingCard({ onBack, onSave }) {
const [coffeeName, setCoffeeName] = React.useState('Gesha ยท Lot 47');
const [roaster, setRoaster] = React.useState('April Coffee');
const [altitude, setAltitude] = React.useState('1850');
const [dose, setDose] = React.useState('18.0');
const [yield_, setYield] = React.useState('300');
const [grind, setGrind] = React.useState('24 clicks');
const [temp, setTemp] = React.useState('94');
const [tempUnit, setTempUnit] = React.useState('ยฐC');
const [time, setTime] = React.useState('3:15');
const [tds, setTds] = React.useState('1.35');
const [method, setMethod] = React.useState('V60');
const [flavors, setFlavors] = React.useState(['fruity', 'floral', 'sweet']);
const [acidity, setAcidity] = React.useState(7);
const [body, setBody] = React.useState(5);
const [balance, setBalance] = React.useState(8);
const [sweetness, setSweetness] = React.useState(7);
const [aromaHot, setAromaHot] = React.useState(['๐ธ Jasmine', '๐ Peach']);
const [aromaCold, setAromaCold] = React.useState(['๐ฏ Honey']);
const [tasteHot, setTasteHot] = React.useState(['๐ Bergamot', '๐ Apricot']);
const [tasteCold, setTasteCold] = React.useState(['๐ Strawberry', '๐ฏ Maple']);
const [obs, setObs] = React.useState('Rinsed paper at 96ยฐC. 50g bloom ร 30s, then 4 pulses to 300g. Drawdown 2:50.');
const [cvaEnabled, setCvaEnabled] = React.useState(false);
const [cvaScores, setCvaScores] = React.useState({
fragrance_aroma: 8.0, flavor: 8.0, aftertaste: 8.0,
acidity: 8.0, body: 8.0, balance: 8.0, sweetness: 8.0,
uniformity: 8.0, clean_cup: 8.0, overall: 8.0,
});
const [cvaDefects, setCvaDefects] = React.useState({ taint: 0, fault: 0 });
const [pickerCtx, setPickerCtx] = React.useState(null);
const cvaTotal = React.useMemo(() => {
const sum = Object.values(cvaScores).reduce((a, v) => a + v, 0);
return Math.max(0, sum - cvaDefects.taint - cvaDefects.fault);
}, [cvaScores, cvaDefects]);
const handleSave = () => onSave({
coffee_name: coffeeName,
roaster: roaster || null,
brew_method: method || null,
altitude: altitude ? parseInt(altitude) : null,
dose: dose ? parseFloat(dose) : null,
yield_g: yield_ ? parseFloat(yield_) : null,
grind: grind || null,
water_temp: temp ? parseInt(temp) : null,
temp_unit: tempUnit,
brew_time: time || null,
tds: tds ? parseFloat(tds) : null,
flavors,
acidity: cvaEnabled ? null : acidity,
body: cvaEnabled ? null : body,
balance: cvaEnabled ? null : balance,
sweetness: cvaEnabled ? null : sweetness,
aroma_hot: aromaHot,
aroma_cold: aromaCold,
taste_hot: tasteHot,
taste_cold: tasteCold,
observations: obs || null,
cva_enabled: cvaEnabled,
cva_fragrance_aroma: cvaEnabled ? cvaScores.fragrance_aroma : null,
cva_flavor: cvaEnabled ? cvaScores.flavor : null,
cva_aftertaste: cvaEnabled ? cvaScores.aftertaste : null,
cva_acidity: cvaEnabled ? cvaScores.acidity : null,
cva_body: cvaEnabled ? cvaScores.body : null,
cva_balance: cvaEnabled ? cvaScores.balance : null,
cva_sweetness: cvaEnabled ? cvaScores.sweetness : null,
cva_uniformity: cvaEnabled ? cvaScores.uniformity : null,
cva_clean_cup: cvaEnabled ? cvaScores.clean_cup : null,
cva_overall: cvaEnabled ? cvaScores.overall : null,
cva_defects_taint: cvaEnabled ? cvaDefects.taint : null,
cva_defects_fault: cvaEnabled ? cvaDefects.fault : null,
cva_total_score: cvaEnabled ? cvaTotal : null,
});
const toggleFlavor = (k) => {
setFlavors(f => f.includes(k) ? f.filter(x => x !== k) : [...f, k]);
};
const openPicker = (target) => setPickerCtx(target);
const handlePick = (note) => {
if (!pickerCtx) return;
const map = {
'aromaHot': [aromaHot, setAromaHot],
'aromaCold': [aromaCold, setAromaCold],
'tasteHot': [tasteHot, setTasteHot],
'tasteCold': [tasteCold, setTasteCold],
};
const [arr, setter] = map[pickerCtx];
setter([...arr, note]);
};
const removeFrom = (target, i) => {
const map = {
'aromaHot': [aromaHot, setAromaHot],
'aromaCold': [aromaCold, setAromaCold],
'tasteHot': [tasteHot, setTasteHot],
'tasteCold': [tasteCold, setTasteCold],
};
const [arr, setter] = map[target];
setter(arr.filter((_, idx) => idx !== i));
};
// Live brew ratio โ recomputes whenever dose or yield changes.
const ratio = React.useMemo(() => {
const d = parseFloat(dose);
const y = parseFloat(yield_);
if (!d || !y || d <= 0) return null;
return (y / d).toFixed(2);
}, [dose, yield_]);
const ratioLabel = ratio ? `1:${ratio}` : 'โ';
const ratioKind =
!ratio ? 'neutral' :
parseFloat(ratio) <= 3 ? 'espresso' :
parseFloat(ratio) <= 8 ? 'concentrated' :
parseFloat(ratio) <= 14 ? 'short' :
parseFloat(ratio) <= 18 ? 'classic' :
'long';
const ratioTone = {
neutral: { bg: 'rgba(36,23,18,0.06)', fg: PALETTE.inkMuted, lbl: 'enter dose & yield' },
espresso: { bg: 'rgba(122,74,54,0.16)', fg: '#5A2F1E', lbl: 'espresso range' },
concentrated: { bg: 'rgba(184,121,74,0.18)', fg: '#7A4A2A', lbl: 'concentrated' },
short: { bg: 'rgba(124,154,98,0.16)', fg: '#3F5E2C', lbl: 'short brew' },
classic: { bg: 'rgba(124,154,98,0.22)', fg: '#2E4A1F', lbl: 'classic filter' },
long: { bg: 'rgba(43,124,217,0.14)', fg: '#1F4F8F', lbl: 'long brew' },
}[ratioKind];
return (
{/* Top nav */}
Tasting ยท Draft
{/* Header info */}
{/* Pro brew section */}
Pro brew
{/* Live ratio readout โ auto-updates from Dose & Yield */}
Brew ratio
{ratioTone.lbl}
{ratioLabel}
{/* Grind is free text by design โ "24 clicks", "fine", grinder model etc. */}
setTemp(sanitizeNumeric(e.target.value, 'int'))}
style={{ background: 'transparent', border: 'none', outline: 'none',
fontSize: 18, fontWeight: 600, color: PALETTE.espresso,
fontFeatureSettings: '"tnum"', width: 32, padding: 0,
fontFamily: '-apple-system, system-ui' }} />
{/* digits + one colon ("3:15"); keeps the default keyboard โ numeric keypads have no colon */}
{
let v = e.target.value.replace(/[^\d:]/g, '');
const i = v.indexOf(':');
if (i !== -1) v = v.slice(0, i + 1) + v.slice(i + 1).replace(/:/g, '');
setTime(v);
}}
style={{ background: 'transparent', border: 'none', outline: 'none',
fontSize: 18, fontWeight: 600, color: PALETTE.espresso,
fontFeatureSettings: '"tnum"', width: '100%', padding: 0,
fontFamily: '-apple-system, system-ui' }} />
{Icon.clock(14)}
{/* Flavor wheel */}
Flavor wheel
{flavors.map(k => {
const f = FLAVORS.find(x => x.key === k);
return (
{f.label}
);
})}
{flavors.length === 0 && (
none yet โ tap a sector
)}
{/* Sensory / CVA toggle */}
Sensory scales
SCA CVA
setCvaEnabled(e => !e)} style={{
width: 44, height: 26, borderRadius: 13,
background: cvaEnabled ? PALETTE.sageDeep : PALETTE.inkSoft,
position: 'relative', cursor: 'pointer', transition: 'background 200ms',
flexShrink: 0,
}}>
{!cvaEnabled ? (
{[
{ label: 'Acidity', val: acidity, set: setAcidity, l: 'Soft', r: 'Bright' },
{ label: 'Body', val: body, set: setBody, l: 'Tea-like', r: 'Syrupy' },
{ label: 'Balance', val: balance, set: setBalance, l: 'Loose', r: 'Resolved' },
{ label: 'Sweetness', val: sweetness, set: setSweetness, l: 'Dry', r: 'Sugary' },
].map(s => (
))}
) : (
{/* Score hero */}
{(() => {
const g = cvaGradeInfo(cvaTotal);
return (
);
})()}
{/* 10 attribute sliders */}
{CVA_ATTRS.map(({ key, label }) => (
{label}
{cvaScores[key].toFixed(2)} /10
setCvaScores(s => ({ ...s, [key]: v }))}
min={6} max={10} step={0.25}
ticks={[6, 7, 8, 9, 10]}
/>
))}
{/* Defect counters */}
Defects
setCvaDefects(d => ({ ...d, taint: v }))}
/>
setCvaDefects(d => ({ ...d, fault: v }))}
/>
{(cvaDefects.taint > 0 || cvaDefects.fault > 0) && (
Penalty: โ{cvaDefects.taint + cvaDefects.fault} pts
)}
)}
{/* Hot/Cold descriptors */}
Aromas
openPicker('aromaHot')}
onAddCold={() => openPicker('aromaCold')}
onRemoveHot={(i) => removeFrom('aromaHot', i)}
onRemoveCold={(i) => removeFrom('aromaCold', i)}
/>
Tasting notes
openPicker('tasteHot')}
onAddCold={() => openPicker('tasteCold')}
onRemoveHot={(i) => removeFrom('tasteHot', i)}
onRemoveCold={(i) => removeFrom('tasteCold', i)}
/>
{/* Notes */}
Brewing observations
setPickerCtx(null)} onPick={handlePick} />
);
}
Object.assign(window, { TastingCard });