diff --git a/app/src/components/Calculator.jsx b/app/src/components/Calculator.jsx index 030d469..78114bd 100644 --- a/app/src/components/Calculator.jsx +++ b/app/src/components/Calculator.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useId } from 'react'; import { Link } from 'react-router-dom'; import { ACRONYMS, FISH_DATA_V3, PROFILES_DATA } from '../data/fish_data_v3'; -import { Info, Calculator as CalcIcon, Save, HelpCircle, Download, ChevronRight, ChevronDown } from 'lucide-react'; +import { Calculator as CalcIcon, Save, HelpCircle, Download, ChevronDown } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { apiUrl } from '../config/api'; +import { calculate } from '../lib/calcEngine'; +import { parseAmount } from '../lib/numberInput'; /** * Help bubble that works for mouse (hover), keyboard (focus) and touch (tap). @@ -96,60 +98,128 @@ const Tooltip = ({ text, label, iconOnly = false, children }) => { ); }; -const TextWithTooltips = ({ text }) => { - if (!text) return null; - const sortedAcronyms = Object.keys(ACRONYMS).sort((a, b) => b.length - a.length); - let parts = [{ text, isAcronym: false }]; - - sortedAcronyms.forEach(acronym => { - const newParts = []; - parts.forEach(part => { - if (part.isAcronym) { newParts.push(part); return; } - const regex = new RegExp(`(${acronym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'g'); - const splits = part.text.split(regex); - splits.forEach(s => { - if (s === acronym) newParts.push({ text: s, isAcronym: true, tooltip: ACRONYMS[acronym] }); - else if (s) newParts.push({ text: s, isAcronym: false }); - }); - }); - parts = newParts; - }); +/** One big tap target in a group of choices (a radio button styled as a tile). */ +const Tile = ({ name, value, checked, onChange, label, sub }) => ( + +); + +const stepButton = + 'min-h-[3.5rem] rounded-xl border-2 border-line-strong bg-surface-raised text-3xl font-bold leading-none text-text-primary transition-colors hover:border-accent active:translate-y-px active:bg-surface'; +/** A number field with big − / + buttons either side, for wet or gloved hands. */ +const Stepper = ({ id, value, onChange, step, format, prefix, suffix, lessLabel, moreLabel, placeholder, describedBy }) => { + const inputRef = useRef(null); + const invalid = String(value).trim() !== '' && Number.isNaN(parseAmount(value)); + const bump = (delta) => onChange(format(Math.max(0, (parseAmount(value) || 0) + delta))); return ( - <> - {parts.map((part, i) => - part.isAcronym ? ( - - {part.text} - - ) : ( - {part.text} - ) - )} - +
+ + {/* The input is sized to its text so the $ or unit sits right beside the number; a tap anywhere in the box focuses it */} +
inputRef.current?.focus()} + className="flex cursor-text items-center justify-center gap-1 overflow-hidden rounded-xl border-2 border-line-strong bg-surface-raised px-3 has-[[aria-invalid=true]]:border-danger focus-within:outline focus-within:outline-[3px] focus-within:outline-offset-2 focus-within:outline-[color:var(--color-focus)]"> + {prefix && } + onChange(e.target.value)} + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + className="min-h-[3.25rem] min-w-0 max-w-full bg-transparent text-center text-2xl font-extrabold tabular-nums text-text-primary placeholder-text-muted focus:outline-none" + /> + {suffix && } +
+ +
); }; -const RangeButton = ({ active, onClick, label }) => ( - -); +const money = (n) => n.toFixed(2); +const plainNumber = (n) => String(Number(n.toFixed(2))); +const dollars = (n) => `$${n.toFixed(2)}`; -const StepHeading = ({ number, children }) => ( -

+/** + * A per-lb charge (processing or shipping) and which weight it is charged on. + * Processors differ: some bill every pound you drop off (incoming), some only the pounds you take back (outgoing). + */ +const ExtraCost = ({ id, title, amount, onAmount, basis, onBasis, basisLegend, fromState, toState, perFinishedLb, yieldPercent }) => { + const hintId = `${id}-hint`; + const charged = parseAmount(amount) > 0; + let hint = `Pick which weight the ${title.toLowerCase()} price is per pound of.`; + if (charged && basis === 'incoming') { + hint = `${dollars(parseAmount(amount))} per lb of ${fromState} works out to ${dollars(perFinishedLb)} per lb of ${toState} at ${yieldPercent}% yield.`; + } else if (charged) { + hint = `${dollars(parseAmount(amount))} per lb of ${toState}, added as is.`; + } + return ( +
+ + +
+ {basisLegend} +
+ onBasis('incoming')} + label="Incoming weight" + sub={`lbs of ${fromState}`} + /> + onBasis('outgoing')} + label="Outgoing weight" + sub={`lbs of ${toState}`} + /> +
+
+

{hint}

+
+ ); +}; + +const StepHeading = ({ number, children, id }) => ( +

@@ -157,6 +227,8 @@ const StepHeading = ({ number, children }) => (

); +const TO_LIMIT = 6; + const Calculator = () => { const { user, getAuthHeaders } = useAuth(); const [mode, setMode] = useState('cost'); @@ -164,20 +236,17 @@ const Calculator = () => { const [species, setSpecies] = useState(''); const [fromState, setFromState] = useState(''); const [toState, setToState] = useState(''); + const [showAllTo, setShowAllTo] = useState(false); const [cost, setCost] = useState(''); const [yieldPercent, setYieldPercent] = useState(''); - const [yieldRange, setYieldRange] = useState(null); const [processingCost, setProcessingCost] = useState(''); - const [coldStorage, _setColdStorage] = useState(''); - const [shipping, _setShipping] = useState(''); const [weightType, setWeightType] = useState('incoming'); - const [result, setResult] = useState(null); - // Yield and target as they were when the result was computed, so the description never drifts from the number - const [resultMeta, setResultMeta] = useState(null); - const resultRef = useRef(null); - const [saveStatus, setSaveStatus] = useState(''); - const [useRangeMin, setUseRangeMin] = useState(false); - const [useRangeMax, setUseRangeMax] = useState(false); + const [shipping, setShipping] = useState(''); + const [shippingWeightType, setShippingWeightType] = useState('outgoing'); + // Which inputs the last save was for, so "Saved" disappears as soon as anything changes + const [saveState, setSaveState] = useState({ key: null, text: '' }); + const [announcement, setAnnouncement] = useState(''); + const dockRef = useRef(null); const [customData, setCustomData] = useState({}); const [_history, setHistory] = useState([]); @@ -248,24 +317,20 @@ const Calculator = () => { const speciesList = Object.keys(combinedData).sort(); + const conversionsFor = (sp) => Object.values(combinedData[sp]?.conversions || {}); + + // Kept in the data's order (Round → D/H-On → D/H-Off …), which follows the cutting line, with Round first const fromStates = useMemo(() => { if (!species || !combinedData[species]) return []; - const states = new Set(); - Object.values(combinedData[species].conversions || {}).forEach(conv => { - if (conv.from) states.add(conv.from); - }); - return Array.from(states).sort(); + const states = [...new Set(Object.values(combinedData[species].conversions || {}).map(c => c.from).filter(Boolean))]; + return states.includes('Round') ? ['Round', ...states.filter(s => s !== 'Round')] : states; }, [species, combinedData]); const toStates = useMemo(() => { if (!species || !fromState || !combinedData[species]) return []; - const states = []; - Object.values(combinedData[species].conversions || {}).forEach(conv => { - if (conv.from === fromState && conv.to) { - states.push({ to: conv.to, yield: conv.yield, range: conv.range }); - } - }); - return states.sort((a, b) => a.to.localeCompare(b.to)); + return Object.values(combinedData[species].conversions || {}) + .filter(conv => conv.from === fromState && conv.to) + .map(conv => ({ to: conv.to, yield: conv.yield, range: conv.range })); }, [species, fromState, combinedData]); const currentConversion = useMemo(() => { @@ -277,71 +342,84 @@ const Calculator = () => { const profile = species ? profilesData[species] : null; const scientificName = species && combinedData[species] ? combinedData[species].scientific_name : null; + const yieldRange = currentConversion?.range || null; - const handleSpeciesChange = (e) => { - setSpecies(e.target.value); - setFromState(''); setToState(''); setYieldPercent(''); setYieldRange(null); setResult(null); + const chooseFrom = (from) => { + setFromState(from); + setToState(''); setYieldPercent(''); setShowAllTo(false); }; - const handleFromChange = (e) => { - setFromState(e.target.value); - setToState(''); setYieldPercent(''); setYieldRange(null); setResult(null); + const handleSpeciesChange = (e) => { + const sp = e.target.value; + setSpecies(sp); + // Most people start from the whole fish, so pick it for them (or the only choice there is) + const froms = [...new Set(conversionsFor(sp).map(c => c.from).filter(Boolean))]; + chooseFrom(froms.includes('Round') ? 'Round' : froms.length === 1 ? froms[0] : ''); }; - const handleToChange = (e) => { setToState(e.target.value); setResult(null); }; + const chooseTo = (to) => { + setToState(to); + const conv = toStates.find(t => t.to === to); + setYieldPercent(conv ? String(conv.yield) : ''); + }; + // The answer is worked out live from what is on screen, so it can never describe different numbers + const ready = Boolean(species && fromState && toState); + const mainInput = mode === 'cost' ? cost : targetWeight; + const hasMainInput = Number.isFinite(parseAmount(mainInput)); + // A box with text that isn't a number would otherwise count as 0 and give a wrong answer, so show none + const badInput = [mainInput, yieldPercent, ...(mode === 'cost' ? [processingCost, shipping] : [])] + .some(v => String(v).trim() !== '' && Number.isNaN(parseAmount(v))); + const calc = useMemo(() => { + if (!ready) return null; + return calculate({ + mode, + yieldPercent: parseAmount(yieldPercent), + targetWeight: parseAmount(targetWeight), + cost: parseAmount(cost), + processingCost: parseAmount(processingCost), + weightType, + shipping: parseAmount(shipping), + shippingWeightType, + }); + }, [ready, mode, yieldPercent, targetWeight, cost, processingCost, weightType, shipping, shippingWeightType]); + const result = calc && hasMainInput && !badInput ? calc.result : null; + + const inputsKey = JSON.stringify([ + mode, species, fromState, toState, cost, targetWeight, yieldPercent, + processingCost, weightType, shipping, shippingWeightType, + ]); + const saveStatus = saveState.key === inputsKey ? saveState.text : ''; + + const numbersOnly = 'Use numbers only in the boxes, like 4.50 or 1,000'; + let resultSentence = ''; + if (result !== null) { + resultSentence = mode === 'cost' ? `${dollars(result)} per lb of ${toState}` : `Buy ${result.toFixed(1)} lbs of ${fromState}`; + } else if (ready && badInput) { + resultSentence = numbersOnly; + } + + // Screen readers hear the answer once typing pauses, not on every keystroke useEffect(() => { - if (currentConversion) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setYieldPercent(String(currentConversion.yield)); - setYieldRange(currentConversion.range); - setUseRangeMin(false); - setUseRangeMax(false); - } - }, [currentConversion]); + const timer = setTimeout(() => setAnnouncement(resultSentence), 900); + return () => clearTimeout(timer); + }, [resultSentence]); + // Tabbing or scrolling a field into view must not leave it under the pinned result bar (WCAG 2.4.11) useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (yieldRange && useRangeMin) setYieldPercent(String(yieldRange[0])); - else if (yieldRange && useRangeMax) setYieldPercent(String(yieldRange[1])); - else if (currentConversion && !useRangeMin && !useRangeMax) setYieldPercent(String(currentConversion.yield)); - }, [useRangeMin, useRangeMax, yieldRange, currentConversion]); - - const calculate = () => { - const y = (parseFloat(yieldPercent) || 100) / 100; - - if (mode === 'weight') { - const target = parseFloat(targetWeight) || 0; - setResult(y > 0 ? target / y : 0); - setResultMeta({ yieldPercent, targetWeight }); - setSaveStatus(''); - return; - } - - const c = parseFloat(cost) || 0; - const proc = parseFloat(processingCost) || 0; - const cold = parseFloat(coldStorage) || 0; - const ship = parseFloat(shipping) || 0; - - let baseRes = c / y; - if (weightType === 'incoming') baseRes += proc / y; - else baseRes += proc; - baseRes += cold + ship; - - setResult(baseRes); - setResultMeta({ yieldPercent, targetWeight }); - setSaveStatus(''); - }; - - // Bring a fresh result into view: on a phone it lands below the fold, under the keyboard - useEffect(() => { - if (result === null || !resultRef.current) return; - const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - resultRef.current.scrollIntoView?.({ block: 'nearest', behavior: reduceMotion ? 'auto' : 'smooth' }); - }, [result]); + const bar = dockRef.current; + if (!bar || typeof ResizeObserver === 'undefined') return undefined; + const root = document.documentElement; + const apply = () => { root.style.scrollPaddingBottom = `${bar.offsetHeight + 16}px`; }; + apply(); + const observer = new ResizeObserver(apply); + observer.observe(bar); + return () => { observer.disconnect(); root.style.scrollPaddingBottom = ''; }; + }, []); const handleSave = async () => { - if (!user || !result) return; + if (!user || result === null) return; + const key = inputsKey; try { const headers = await getAuthHeaders('application/json'); const res = await fetch(apiUrl('/api/save-calc'), { @@ -350,14 +428,14 @@ const Calculator = () => { body: JSON.stringify({ name: `${species} - ${fromState} → ${toState}`, species, product: `${fromState} → ${toState}`, - mode, cost: mode === 'cost' ? parseFloat(cost) : 0, - target_weight: mode === 'weight' ? parseFloat(targetWeight) : 0, - yield: parseFloat(yieldPercent), result + mode, cost: mode === 'cost' ? parseAmount(cost) : 0, + target_weight: mode === 'weight' ? parseAmount(targetWeight) : 0, + yield: parseAmount(yieldPercent), result }) }); - setSaveStatus(res.ok ? 'Saved!' : 'Failed to save'); + setSaveState({ key, text: res.ok ? 'Saved!' : 'Failed to save' }); } catch { - setSaveStatus('Error saving'); + setSaveState({ key, text: 'Error saving' }); } }; @@ -399,377 +477,288 @@ const Calculator = () => { } }; - const canCalculate = species && toState; + const shownTo = showAllTo || toStates.length <= TO_LIMIT + ? toStates + : toStates.filter((t, i) => i < TO_LIMIT || t.to === toState); - const handleSubmit = (e) => { - e.preventDefault(); - if (canCalculate) calculate(); - }; + const presets = currentConversion && yieldRange + ? [['Low', yieldRange[0]], ['Average', currentConversion.yield], ['High', yieldRange[1]]] + : []; + + const extras = calc?.breakdown + ? [['Processing', calc.breakdown.processing], ['Shipping', calc.breakdown.shipping]].filter(([, v]) => v > 0) + : []; + + let dockPrompt = 'Pick a species, what you have, and what you’re making'; + if (ready && badInput) dockPrompt = numbersOnly; + else if (ready) dockPrompt = mode === 'cost' ? `Enter what you pay per lb of ${fromState}` : `Enter how many lbs of ${toState} you need`; return ( -
- {/* Page header */} +
-

Fish Cost Calculator

+

Fish Cost Calculator

- See what your fish really costs per pound after cutting and trimming. + Tap your fish. Your real cost per pound shows at the bottom the whole time.

- {/* Main calculator card */} -
- {/* Mode toggle */} -
- {[ - { id: 'cost', label: 'Cost per pound' }, - { id: 'weight', label: 'Pounds to buy' }, - ].map(({ id, label }) => ( - + ))} +
+ + {/* Step 1: the fish */} +
+ Your fish + +
+ + {dataLoading ? ( +
Loading species data…
+ ) : ( + + )} + {scientificName &&

{scientificName}

}
-
- {/* Step 1: the fish */} -
-
Your fish
- -
- - {dataLoading ? ( -
Loading species data…
- ) : ( - - )} - {scientificName && ( -

{scientificName}

- )} -
- -
-
-
- - - -
- -
- -
-
- - - -
- -
-
- - {/* Conversion info */} - {currentConversion && ( -
-
-
-
- - - - - - - Average yield - {currentConversion.yield}% - - {currentConversion.range && ( - - Typical range - {currentConversion.range[0]}–{currentConversion.range[1]}% - - )} -
- - {currentConversion.range && ( -
-

- Yield changes with fish size and cutting skill. Pick what fits your shop: -

-
- { setUseRangeMin(true); setUseRangeMax(false); }} - label={`Low ${currentConversion.range[0]}%`} - /> - { setUseRangeMin(false); setUseRangeMax(false); }} - label={`Average ${currentConversion.yield}%`} - /> - { setUseRangeMax(true); setUseRangeMin(false); }} - label={`High ${currentConversion.range[1]}%`} - /> -
-
- )} +
+ + What you have + + + + {species ? ( + fromStates.length > 0 ? ( +
+ {fromStates.map(f => ( + chooseFrom(f)} label={f} /> + ))}
- )} - - {/* Species profile */} - {profile && ( -
- {profile.description &&

{profile.description}

} - {profile.edible_portions && ( -

Edible portions: {profile.edible_portions}

- )} - {profile.url && ( - - Read more → - - )} -
- )} -
- -
- - {/* Step 2: the numbers */} -
-
Your numbers
- -
- {mode === 'cost' ? ( -
- -
- - setCost(e.target.value)} - className="form-input pl-8 tabular-nums" - placeholder="0.00" - inputMode="decimal" - /> -
-
- ) : ( -
- -
- setTargetWeight(e.target.value)} - className="form-input pr-12 tabular-nums" - placeholder="e.g. 100" - inputMode="decimal" - /> - -
-
- )} - -
- -
- { setYieldPercent(e.target.value); setUseRangeMin(false); setUseRangeMax(false); }} - className="form-input pr-10 tabular-nums" - placeholder="0" - inputMode="decimal" - aria-describedby={yieldRange ? 'calc-yield-hint' : undefined} + ) : ( +

No yield data for this species yet.

+ ) + ) : ( +

Choose a species first.

+ )} + + +
+ + What you're making + + + + {fromState ? ( + <> +
+ {shownTo.map(t => ( + chooseTo(t.to)} + label={t.to} + sub={`${t.yield}% yield`} /> - -
- {yieldRange && ( -

- Typical range: {yieldRange[0]}–{yieldRange[1]}% -

- )} + ))}
-
- - {/* Additional costs (cost mode only) */} - {mode === 'cost' && ( -
-
- -
- - setProcessingCost(e.target.value)} - className="form-input pl-8 tabular-nums" - placeholder="0.00" - inputMode="decimal" - /> -
-
+ {toStates.length > TO_LIMIT && ( + + )} + + ) : ( +

Choose what you have first.

+ )} + - {/* Only ask which weight it applies to once there is a processing cost */} - {processingCost !== '' && ( -
- - -
- )} -
+ {profile && ( +
+ {profile.description &&

{profile.description}

} + {profile.edible_portions && ( +

Edible portions: {profile.edible_portions}

)} -
- - {/* Calculate */} -
- - {!canCalculate && ( -

- Choose a species, what you have, and what you're making to get started. -

+ {profile.url && ( + + Read more → + )}
- - - {/* Result: the live region stays mounted (so the first result is announced) and holds only the - answer. Actions and save status live outside it to avoid duplicate announcements. */} -
-
-
- {result !== null && ( - <> -

- {mode === 'cost' ? `Your cost per lb of ${toState}` : `You need to buy (${fromState})`} -

-

- {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`} -

-

- {mode === 'cost' - ? `At ${resultMeta?.yieldPercent}% yield from ${fromState} to ${toState}` - : `${result.toFixed(1)} lbs of ${fromState} makes ${resultMeta?.targetWeight} lbs of ${toState}` - } -

- - )} + )} +
+ + {/* Step 2: the numbers */} +
+ Your numbers + + {mode === 'cost' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + +
+ + +

+ {!currentConversion + ? 'Choose what you’re making to fill this in.' + : yieldRange + ? `Typical ${yieldRange[0]}–${yieldRange[1]}%. It changes with fish size and cutting skill.` + : 'No typical range reported for this cut.'} +

+ {presets.length > 0 && ( +
+ {presets.map(([name, value]) => ( + + ))}
+ )} +
- {result !== null && (user ? ( -
- - - - - {saveStatus} - -
- ) : ( -

- Want to keep this? Sign in to save your calculations. -

- ))} + {mode === 'cost' && ( +
+

Other costs

+ +
-
-
+ )} + + + {/* Keep it */} + {result !== null && ( + user ? ( +
+ + + + + {saveStatus} + +
+ ) : ( +

+ Want to keep this? Sign in to save your calculations. +

+ ) + )} {/* Abbreviation reference (collapsed: regulars know these, newcomers can open it) */}
@@ -796,18 +785,18 @@ const Calculator = () => { Recent community calculations
); }; diff --git a/app/src/lib/calcEngine.js b/app/src/lib/calcEngine.js index de71238..f000feb 100644 --- a/app/src/lib/calcEngine.js +++ b/app/src/lib/calcEngine.js @@ -52,7 +52,10 @@ * 'incoming' — processingCost is divided by yield (applied to Round lbs) * 'outgoing' — processingCost is added directly (applied to finished-product lbs) * @property {number|string} [coldStorage=0] $/lb cold-storage addend (finished-product basis) - * @property {number|string} [shipping=0] $/lb shipping addend (finished-product basis) + * @property {number|string} [shipping=0] $/lb shipping charge, applied to either incoming or outgoing weight. + * @property {'incoming'|'outgoing'} [shippingWeightType='outgoing'] + * 'incoming' — shipping the starting fish; divided by yield like incoming processing + * 'outgoing' — shipping the finished product; added directly * * @property {boolean} [showTimeTracking=false] * When true, labor costs from processingSteps are summed and added per lb. @@ -74,6 +77,9 @@ * weight mode: lbs of Round needed * @property {number} appliedDiscount * Percentage discount applied (0 when none). Always 0 in weight mode. + * @property {{fish: number, processing: number, shipping: number, coldStorage: number, labor: number}|null} breakdown + * What each part adds per lb of finished product, before any discount (the parts sum to the + * undiscounted result). null in weight mode. */ /** @@ -92,6 +98,7 @@ export function calculate(inputs) { weightType = 'incoming', coldStorage = 0, shipping = 0, + shippingWeightType = 'outgoing', showTimeTracking = false, processingSteps = [], showEconomyOfScale = false, @@ -109,6 +116,7 @@ export function calculate(inputs) { return { result: y > 0 ? target / y : 0, appliedDiscount: 0, + breakdown: null, }; } @@ -118,30 +126,29 @@ export function calculate(inputs) { const cold = parseFloat(coldStorage) || 0; const ship = parseFloat(shipping) || 0; - // Base cost: raw $/lb divided by yield fraction - let baseRes = c / y; - - // Processing cost: incoming weight → divide by yield; outgoing → add directly - if (weightType === 'incoming') { - baseRes += proc / y; - } else { - baseRes += proc; - } - - // Cold storage and shipping are per-lb addends on the finished-product basis - baseRes += cold + ship; + // A per-lb charge on incoming (starting) weight is spread over fewer finished pounds, so it is + // divided by yield; a charge on outgoing (finished) weight is added as-is. + const perFinishedLb = (amount, basis) => (basis === 'incoming' ? amount / y : amount); // Labor / time-tracking costs: summed across steps, added per lb + let labor = 0; if (showTimeTracking) { - let totalTimeCost = 0; processingSteps.forEach(step => { const time = parseFloat(step.timeMinutes) || 0; const laborRate = parseFloat(step.laborCostPerHour) || 0; - totalTimeCost += (time / 60) * laborRate; + labor += (time / 60) * laborRate; }); - baseRes += totalTimeCost; } + const breakdown = { + fish: c / y, + processing: perFinishedLb(proc, weightType), + shipping: perFinishedLb(ship, shippingWeightType), + coldStorage: cold, // finished-product basis + labor, + }; + let baseRes = breakdown.fish + breakdown.processing + breakdown.shipping + breakdown.coldStorage + breakdown.labor; + // Economy-of-scale discount: find highest qualifying tier let appliedDiscount = 0; if (showEconomyOfScale && quantity) { @@ -159,5 +166,5 @@ export function calculate(inputs) { baseRes = baseRes * (1 - appliedDiscount / 100); } - return { result: baseRes, appliedDiscount }; + return { result: baseRes, appliedDiscount, breakdown }; } diff --git a/app/src/lib/calcEngine.test.js b/app/src/lib/calcEngine.test.js index 1728d6a..60e9aba 100644 --- a/app/src/lib/calcEngine.test.js +++ b/app/src/lib/calcEngine.test.js @@ -157,6 +157,78 @@ describe('calculate — cold storage and shipping addends', () => { }); }); +// --------------------------------------------------------------------------- +// Shipping — incoming vs outgoing weight basis +// --------------------------------------------------------------------------- + +describe('calculate — shipping weight basis', () => { + const cases = [ + { + label: 'incoming basis: shipping the starting fish is spread over the finished pounds', + // cost=2, ship=0.20, yield=50%: 2/0.5 + 0.20/0.5 = 4.40 + inputs: { mode: 'cost', yieldPercent: 50, cost: 2, shipping: 0.2, shippingWeightType: 'incoming' }, + expected: { result: 4.4, appliedDiscount: 0 }, + }, + { + label: 'outgoing basis: shipping the finished product is added directly', + // cost=2, ship=0.20, yield=50%: 2/0.5 + 0.20 = 4.20 + inputs: { mode: 'cost', yieldPercent: 50, cost: 2, shipping: 0.2, shippingWeightType: 'outgoing' }, + expected: { result: 4.2, appliedDiscount: 0 }, + }, + { + label: 'processing and shipping bases are independent', + // cost=2, proc=0.50 outgoing, ship=0.20 incoming, yield=50%: 4 + 0.50 + 0.40 = 4.90 + inputs: { + mode: 'cost', yieldPercent: 50, cost: 2, + processingCost: 0.5, weightType: 'outgoing', shipping: 0.2, shippingWeightType: 'incoming', + }, + expected: { result: 4.9, appliedDiscount: 0 }, + }, + ]; + + cases.forEach(({ label, inputs, expected }) => { + it(label, () => { + const actual = calculate(inputs); + expect(actual.result).toBeCloseTo(expected.result, 10); + expect(actual.appliedDiscount).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Breakdown — what each cost adds per lb of finished product +// --------------------------------------------------------------------------- + +describe('calculate — breakdown', () => { + it('reports each part per lb of finished product, and the parts add up to the result', () => { + // cost=2, proc=0.50 incoming, ship=0.20 incoming, cold=0.10, yield=50% + const actual = calculate({ + mode: 'cost', yieldPercent: 50, cost: 2, + processingCost: 0.5, weightType: 'incoming', shipping: 0.2, shippingWeightType: 'incoming', coldStorage: 0.1, + }); + expect(actual.breakdown.fish).toBeCloseTo(4, 10); + expect(actual.breakdown.processing).toBeCloseTo(1, 10); + expect(actual.breakdown.shipping).toBeCloseTo(0.4, 10); + expect(actual.breakdown.coldStorage).toBeCloseTo(0.1, 10); + expect(actual.breakdown.labor).toBe(0); + const sum = Object.values(actual.breakdown).reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(actual.result, 10); + }); + + it('is taken before any bulk discount', () => { + const actual = calculate({ + mode: 'cost', yieldPercent: 50, cost: 2, + showEconomyOfScale: true, quantity: '100', priceBreaks: DEFAULT_PRICE_BREAKS, + }); + expect(actual.breakdown.fish).toBeCloseTo(4, 10); + expect(actual.result).toBeCloseTo(4 * 0.95, 10); + }); + + it('is null in weight mode', () => { + expect(calculate({ mode: 'weight', yieldPercent: 42, targetWeight: 100 }).breakdown).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Labor / time-tracking costs // --------------------------------------------------------------------------- diff --git a/app/src/lib/numberInput.js b/app/src/lib/numberInput.js new file mode 100644 index 0000000..ddca59b --- /dev/null +++ b/app/src/lib/numberInput.js @@ -0,0 +1,25 @@ +/** + * numberInput.js — read a number the way people type it into a price, weight or yield box. + * + * The calculator's fields are text inputs (so a "$" or "lbs" can sit beside the number), which means + * they accept anything. parseFloat alone gets common entries silently wrong: "$4.50" → NaN, and + * "1,000" → 1. This reads what people actually type and returns NaN for anything it can't be sure + * of, so the UI can ask for a number instead of showing a wrong answer. + */ + +// "$" in front; "%", "lb" or "lbs" behind +const DECORATION = /^\$\s*|\s*(%|lbs?)$/gi; + +/** + * @param {string|number|null|undefined} raw + * @returns {number} the amount, or NaN when the text is not a plain non-negative number + */ +export function parseAmount(raw) { + if (typeof raw === 'number') return Number.isFinite(raw) && raw >= 0 ? raw : NaN; + const text = String(raw ?? '').trim().replace(DECORATION, ''); + + if (/^\d+,\d{1,2}$/.test(text)) return Number(text.replace(',', '.')); // decimal comma: 4,50 + if (/^\d{1,3}(,\d{3})+(\.\d*)?$/.test(text)) return Number(text.replace(/,/g, '')); // thousands: 1,000.5 + if (/^(\d+\.?\d*|\.\d+)$/.test(text)) return Number(text); // plain: 4, 4.5, 4., .5 + return NaN; +} diff --git a/app/src/lib/numberInput.test.js b/app/src/lib/numberInput.test.js new file mode 100644 index 0000000..6fec144 --- /dev/null +++ b/app/src/lib/numberInput.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { parseAmount } from './numberInput.js'; + +describe('parseAmount', () => { + it.each([ + ['4.50', 4.5], + ['4', 4], + ['4.', 4], + ['.5', 0.5], + [' 4.50 ', 4.5], + ['$4.50', 4.5], + ['$ 4.50', 4.5], + ['42%', 42], + ['100 lbs', 100], + ['100lb', 100], + ['1,000', 1000], + ['12,500.75', 12500.75], + ['4,50', 4.5], // decimal comma: one or two digits after a single comma + ['4,5', 4.5], + [4.5, 4.5], + ])('reads %j as %d', (typed, expected) => { + expect(parseAmount(typed)).toBe(expected); + }); + + it.each([ + [''], + [' '], + ['abc'], + ['4a5'], + ['1.2.3'], + ['1,00,0'], + ['-4'], + [null], + [undefined], + [Number.NaN], + ])('rejects %j instead of guessing', (typed) => { + expect(parseAmount(typed)).toBeNaN(); + }); +}); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index ddd7100..7bcac39 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -8,8 +8,8 @@ Supersedes the navy/rust direction in `docs/superpowers/specs/2026-03-27-brandin Fishers, chefs and fishmongers who use apps every day but are busy: on a dock in glare, in a cold-room, in a kitchen mid-service, often on a phone, often with wet or gloved hands. So: -- **Few steps, plain words.** Two numbered steps ("Your fish", "Your numbers"), one primary action, - no jargon in labels (help text explains the industry terms instead). +- **Few steps, plain words.** Two numbered steps ("Your fish", "Your numbers"), no Calculate button + (the answer is always on screen), no jargon in labels (help text explains the industry terms instead). - **Big targets.** Buttons and form controls are at least 48px tall; other tappable things at least 44px. - **Readable in any light.** Text is at least 4.5:1 and form-control edges at least 3:1, in both themes. - **Nothing hover-only.** Help works on tap, keyboard focus and hover. @@ -33,6 +33,27 @@ colors in `app/tailwind.config.js`. Shared classes: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.form-label`, `.form-input`, `.form-select`, `.section-divider`. Inputs are 16px text so iOS does not zoom on focus. +## Calculator layout ("Dockside") + +Chosen from the three mock-ups in `docs/design-mockups/` (direction A). + +- **Tap tiles, not dropdowns,** for "What you have" and "What you're making" (radio buttons styled as + tiles, so arrow keys and screen readers work as for any radio group). Species stays a native select: + there are 89 of them. Products show their yield on the tile; long lists show 6 plus "Show all". +- **Steppers** (big − / + either side of a typed field) for price, pounds, yield, processing and shipping. +- **Live result bar** pinned to the bottom (`bg-brand-teal`, number in `brand-yellow`, 6.7:1). It is worked + out from what is on screen via `app/src/lib/calcEngine.js`, so a saved result always matches its inputs. + When processing or shipping is set it also shows the parts (Fish + Processing + Shipping). Screen readers + get the answer once typing pauses, from a separate live region, not on every keystroke. +- **Processing and shipping** are each charged per lb of **incoming** weight (the starting fish; spread over + fewer finished pounds, so divided by yield) or **outgoing** weight (the finished product; added as is). + Both choices are always visible, with a line spelling out what the charge comes to per finished lb. +- **Number boxes read what people type** (`app/src/lib/numberInput.js`): "$4.50", "1,000", "42%" and "4,50" + all work. Text that isn't a number marks that box as invalid and the bar says to use numbers; it never + counts as 0, because a wrong answer is worse than none. +- **Nothing hides behind the bar.** The calculator sets the page's `scroll-padding-bottom` to the bar's + height, so whatever you Tab to scrolls into view above it (WCAG 2.4.11). + ## Guardrails - `app/src/lib/__tests__/designTokens.test.js` fails if a token pair drops below its contrast target. @@ -47,10 +68,6 @@ Shared classes: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.form- them, so nothing here is derived from those sites. Palette and typeface are unchanged from July 2026. Tokens are centralized, so a palette or font pass is a change to `index.css`, `tailwind.config.js` and the font link in `index.html`. -- **Dark-only status styling** (pale `text-red-300` / `text-green-300` on translucent dark boxes, or - `text-amber-*`) is unreadable in light mode. Move these to `text-danger` / `text-success` and - theme-aware backgrounds: `UploadData`, `SubmitRequest`, `ConflictResolutionModal`, `RecoveryModal`, - `DataManagement`, `ContributorProfile`, `CommunityData`, `FeaturesRoadmap`. - **Unused components** `Footer.jsx` and `InstallPrompt.jsx` are not rendered anywhere and use tokens that no longer exist (`bg-navy`, `text-teal`, `bg-rust`). Restyle before wiring them in, or delete. - **Offline behavior.** `Calculator` seeds its data from `FISH_DATA_V3`, whose conversions have no diff --git a/docs/design-mockups/README.md b/docs/design-mockups/README.md new file mode 100644 index 0000000..fd09d51 --- /dev/null +++ b/docs/design-mockups/README.md @@ -0,0 +1,21 @@ +# Calculator design mock-ups + +Three candidate directions for the calculator screen, plus a visual board that shows them side by side. Each file is self-contained (data, styles and images are inlined), so it opens straight from disk in a browser with no build step or network. + +| File | What it is | +|---|---| +| `visual-board.html` | Overview: phone and desktop views of A, B and C in light and dark mode, plus before/after captures of the accessibility pass merged in #118. Start here. | +| `mockup-a-dockside.html` | **A · Dockside**: big tap tiles, +/− steppers, a live result bar pinned to the bottom, no Calculate button. | +| `mockup-b-market-board.html` | **B · Market Board**: a prep-ticket form and a price tag that shows the math and dims when inputs change, with an explicit "Price it" button. | +| `mockup-c-clear-water.html` | **C · Clear Water**: a fill-in-the-blank sentence with a live, written-out answer. | + +## Status + +- **Direction A (Dockside) was chosen** and is built into the app (`app/src/components/Calculator.jsx`), + with shipping added, and processing and shipping each charged on incoming or outgoing weight (the + mock-up hid the processing choice in a collapsed section; the app shows both up front). See + "Calculator layout" in `docs/DESIGN_SYSTEM.md`. These files stay as the record of the options; the app + does not use them. +- The directions are original and are **not** derived from the reference sites named in the redesign request (Grace Communications, NAMA, NFFC, Local Catch Network). Those sites could not be reached when the mock-ups were made. +- Yields are real values from `app/src/data/fish_data_v3.js` for five species (Pink Salmon, Sockeye Salmon, Pacific Halibut, Pacific Cod, Lingcod), copied in at build time. They will not follow later changes to that file. +- Colors and type follow the current brand tokens in `docs/DESIGN_SYSTEM.md`. Screenshots on the board were taken with a fallback font, so real devices using Inter will look slightly narrower. diff --git a/docs/design-mockups/mockup-a-dockside.html b/docs/design-mockups/mockup-a-dockside.html new file mode 100644 index 0000000..7a3428b --- /dev/null +++ b/docs/design-mockups/mockup-a-dockside.html @@ -0,0 +1,254 @@ + + + + + +Fish Cost Calculator — Mock-up A, Dockside + + + +
Mock-up A · Dockside — live sample with 5 species from the real yield data
+
+
Local Catch
+ +
+ +
+

Fish Cost Calculator

+

Tap your fish. Your real cost per pound shows at the bottom the whole time.

+ +
+ + +
+ +
+

Your fish

+
Species
+
What you have
+
What you're making
+
+
+ +
+

Your numbers

+ +
+ +
+ +
+ +
+
+ + + +
–
+ +
+ +
+ Add a processing cost (optional) +
+ +
+
+
+
Charged per lb of +
+
+
+
+
+ +
+ + + + diff --git a/docs/design-mockups/mockup-b-market-board.html b/docs/design-mockups/mockup-b-market-board.html new file mode 100644 index 0000000..902d200 --- /dev/null +++ b/docs/design-mockups/mockup-b-market-board.html @@ -0,0 +1,215 @@ + + + + + +Fish Cost Calculator — Mock-up B, Market Board + + + +
Mock-up B · Market Board — live sample with 5 species from the real yield data
+
+

Local Catch

+

Fish Cost Calculator

+

Know what a pound of fillet really costs you before you price the case.

+
+ + +
+
+ +
+
+

Prep ticket

+

Fill in the fish, then the numbers. Tap “Price it” for the tag.

+ +
+

+
+
+ +
+
+ + +
+
+
+ +
+
+
+ +
+ + +
+ + +
+ + + + diff --git a/docs/design-mockups/mockup-c-clear-water.html b/docs/design-mockups/mockup-c-clear-water.html new file mode 100644 index 0000000..2f7c61d --- /dev/null +++ b/docs/design-mockups/mockup-c-clear-water.html @@ -0,0 +1,189 @@ + + + + + +Fish Cost Calculator — Mock-up C, Clear Water + + + +
Mock-up C · Clear Water — live sample with 5 species from the real yield data
+
+
+
Local Catch
+ +
+ +
+

Fish Cost Calculator

+

What does your fish really cost?

+
+ + +
+ +
+

+ +
+ +
+ Change the details +
+
Yield how much is left after cutting +
+ +
+
+ + +
+
+
+
+
+
+ + + + diff --git a/docs/design-mockups/visual-board.html b/docs/design-mockups/visual-board.html new file mode 100644 index 0000000..ca8657d --- /dev/null +++ b/docs/design-mockups/visual-board.html @@ -0,0 +1,278 @@ + + + + + +Fish Cost Calculator — Visual Board + + + +
+

Local Catch · Fish Cost Calculator

+

Visual board

+

What pull request #118 changed in the real app, and three new design directions to choose from, all in one file. Every direction runs live, with real yield numbers.

+ +
+ + + +
+
+

1 · What changed in the real app

+

Same fish, same numbers, same moment, captured from the original app (before) and from the PR branch (after). The brand colors and typeface are unchanged; this is about being readable and easy to use with wet hands.

+ +
+

The first screen on a phone

+

Fewer decisions at a glance, and every control big enough to hit.

+
+
Before
Original calculator on a phone: small labels, a wrapped mode toggle, and a washed-out disabled button
+
After
New calculator on a phone: two numbered steps, bold labels, larger controls, and a hint under the disabled button
+
+
  • Two numbered steps, “Your fish” and “Your numbers”, instead of one long form.
  • Plain labels: “What you have” and “What you’re making”, not “From State” and “To Product”.
  • The disabled button now says why, and the mode toggle no longer wraps.
+
+ +
+

The answer, in daylight

+

Result on a phone after tapping Calculate (light theme). The new version scrolls it into view by itself.

+
+
Before
Original result card: small gray caption, medium price, low-contrast Sign in link
+
After
New result card: large price, readable explanation, clear Sign in link
+
+
  • A bigger number with a bordered card, so it reads from arm’s length.
  • Helper text and links now meet WCAG AA contrast (they were 2.6:1 to 4:1).
  • The abbreviation list is collapsed by default and no longer cut off with “…”.
+
+ +
+

The same answer, in the dark

+

Dark theme on a phone, for a dim kitchen or an early boat.

+
+
Before
Original dark result card with a dim button that barely stands out from the card
+
After
New dark result card with a bright teal button and a large light-blue price
+
+
  • The button is lighter than its card, so you can find it at a glance.
  • The price uses a light aqua that holds up on dark backgrounds.
+
+ +
+

A bug fix you can see: invisible headings

+

On dark mode, the About page headings used a dark teal on near-black (about 2:1). They were effectively invisible, and the same was true on Data Sources, Community and Upload.

+
+
Before
Original About page in dark mode: the headings are almost invisible
+
After
New About page in dark mode: headings in light aqua, and the active page underlined
+
+
  • Headings are now light aqua, readable in dark mode.
  • The current page in the navigation is underlined, not just recolored.
+
+ +
+

On a laptop at the counter

+
+
Before
Original desktop calculator with the result below the form
+
After
New desktop calculator with numbered steps and a large result card
+
+
+
+ +
+

2 · Three new directions

+

Three genuinely different ways the same screen could work. They are my own directions for this audience, not copies of any other site. Open “Try it live” under each to click through it: switch fish, change the price, flip modes.

+ +
+

Dockside

Loud, high-contrast, one-thumb. The answer is always on screen.

+
+
Design A on a phone, light theme: big tap tiles and a navy result bar with a yellow price
Phone · light
+
Design A on a phone, dark theme: yellow selected tiles on dark navy
Phone · dark
+
Design A on a desktop, light theme
Desktop · light
+
+
+

How it works

  • Tap a fish, what you have, what you’re making.
  • +/− buttons nudge the price by 25¢.
  • The result bar updates live, so there is no Calculate button.
+

Best for

  • Fishers on the dock, in glare, with gloves.
  • Anyone who wants the answer without hunting for it.
+

Trade-offs

  • The loudest look of the three.
  • Big tiles mean a longer scroll (products are collapsed to the top 6).
+
+
▶ Try Dockside live
Use the ◐ inside to flip light and dark.
+
+ +
+

Market Board

Warm and tactile. A prep ticket in, a price tag out.

+
+
Design B on a phone, light theme: chalkboard header, cream prep ticket
Phone · light
+
Design B on a phone, dark theme
Phone · dark
+
Design B on a desktop: prep ticket on the left and a hanging price tag on the right
Desktop · light
+
+
+

How it works

  • Fill in the ticket, then tap “Price it”.
  • The tag shows the math: $4.50 ÷ 42% yield.
  • Change anything and the tag dims, so it never shows a stale price as if it were current.
+

Best for

  • Chefs and fishmongers pricing a case or a menu.
  • Desktop and tablet at the counter.
+

Trade-offs

  • One extra tap to see the answer.
  • Furthest from today’s brand; serif type depends on the device’s fonts.
+
+
▶ Try Market Board live
Use the ◐ inside to flip light and dark.
+
+ +
+

Clear Water

Calm and conversational. Fill in a sentence, read the answer.

+
+
Design C on a phone, light theme: a sentence with dropdown blanks and a large answer
Phone · light
+
Design C on a phone, dark theme
Phone · dark
+
Design C on a desktop: the fill-in-the-blank sentence and the written-out answer
Desktop · light
+
+
+

How it works

  • “I’m buying [Pink Salmon] as [Round] for $[4.50] a pound, and turning it into [Skinless Fillet].”
  • The answer appears live, with a plain-language reason.
+

Best for

  • New users and calm, low-pressure moments.
  • Anyone put off by forms and jargon.
+

Trade-offs

  • The sentence wraps awkwardly on small phones.
  • Harder to add more fields later without the sentence getting long.
+
+
▶ Try Clear Water live
Use the ◐ inside to flip light and dark.
+
+
+ +
+

3 · Side by side

+

Same data and same math in all three. They differ in how much they ask of you and when you see the answer.

+
+ + + + + + + + + +
Comparison of the three design directions
AspectA · DocksideB · Market BoardC · Clear Water
Big ideaEverything big; the result is always visibleA prep ticket in, a price tag outA sentence you fill in
When you see the priceLive, as you tapAfter “Price it” (dims when out of date)Live, written out in words
FeelBold, high-contrast, work-bootWarm, tactile, chalkboard and paperCalm, airy, conversational
Best inGlare, gloves, one thumbKitchen or fish counter, desktop and tabletFirst-time use, low-stress moments
Change from today’s appLayout, and a live resultLook, and how the result is shownThe whole interaction model
Watch out forLoud; long scroll on species with many cutsAn extra tap; decoration; device-dependent serifAwkward wrapping on small phones; hard to extend
+ +
+

My take (an opinion, not a finding)

+

If I had to test one with real users, I’d start with A for the dock and boat, and C for people new to the tool. They also combine well: A’s always-visible result bar plus C’s plain-language reason (“Only 42% of each Round ends up as Skinless Fillet…”).

+

B is the most distinctive and the friendliest at a counter, but the furthest from the brand you have now.

+
+
+
+ + + + + +