diff --git a/api/_lib/importRows.js b/api/_lib/importRows.js index b206f03..1f14294 100644 --- a/api/_lib/importRows.js +++ b/api/_lib/importRows.js @@ -65,7 +65,22 @@ function parseYieldPercent(value) { return Number.parseFloat(text); } -function rowsFromWorksheet(worksheet) { +// Only XLSX cells formatted as a percentage stay numbers, so normalizeYieldRows +// can scale them (a "42%" cell is stored as 0.42). Every other cell becomes a +// string: a plain 0.5 in an XLSX (including the app's own export) or "0.5" in +// a CSV means 0.5%, not 50%. +function cellValue(cell, keepPercentCells) { + const { value } = cell; + if (keepPercentCells && String(cell.numFmt ?? '').includes('%')) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (value && typeof value === 'object' && typeof value.result === 'number' && Number.isFinite(value.result)) { + return value.result; + } + } + return normalizeCell(value); +} + +function rowsFromWorksheet(worksheet, { keepPercentCells = false } = {}) { const rows = []; const headerRow = worksheet.getRow(1); const headers = []; @@ -83,9 +98,9 @@ function rowsFromWorksheet(worksheet) { const item = {}; headers.forEach((header, colNumber) => { if (!header) return; - item[header] = normalizeCell(row.getCell(colNumber).value); + item[header] = cellValue(row.getCell(colNumber), keepPercentCells); }); - if (Object.values(item).some(Boolean)) rows.push(item); + if (Object.values(item).some((v) => v !== '' && v !== null && v !== undefined)) rows.push(item); }); return rows; @@ -104,7 +119,7 @@ export async function parseImportRows(buffer, extension) { await workbook.xlsx.load(buffer); const worksheet = workbook.worksheets[0]; if (!worksheet) return []; - return rowsFromWorksheet(worksheet); + return rowsFromWorksheet(worksheet, { keepPercentCells: true }); } throw new Error('Unsupported file type. Please upload a .csv or .xlsx file.'); diff --git a/app/src/components/Calculator.jsx b/app/src/components/Calculator.jsx index 030d469..afd2bb4 100644 --- a/app/src/components/Calculator.jsx +++ b/app/src/components/Calculator.jsx @@ -4,6 +4,7 @@ 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 { useAuth } from '../context/AuthContext'; import { apiUrl } from '../config/api'; +import { withConversionStates, hasUsableConversions, parseYieldPercent } from '../lib/fishDataShape'; /** * Help bubble that works for mouse (hover), keyboard (focus) and touch (tap). @@ -183,7 +184,7 @@ const Calculator = () => { const [_history, setHistory] = useState([]); const [publicHistory, setPublicHistory] = useState([]); - const [fishData, setFishData] = useState(FISH_DATA_V3); + const [fishData, setFishData] = useState(() => withConversionStates(FISH_DATA_V3)); const [profilesData, setProfilesData] = useState(PROFILES_DATA); const [dataLoading, _setDataLoading] = useState(false); @@ -191,7 +192,9 @@ const Calculator = () => { fetch(apiUrl('/api/fish-data')) .then(res => res.json()) .then(data => { - if (data.fishData && Object.keys(data.fishData).length > 0) setFishData(data.fishData); + // Keep the bundled data unless the API returns something usable. + const apiData = withConversionStates(data.fishData); + if (hasUsableConversions(apiData)) setFishData(apiData); if (data.profiles && Object.keys(data.profiles).length > 0) setProfilesData(data.profiles); }) .catch(() => {}); @@ -308,7 +311,9 @@ const Calculator = () => { }, [useRangeMin, useRangeMax, yieldRange, currentConversion]); const calculate = () => { - const y = (parseFloat(yieldPercent) || 100) / 100; + const yieldValue = parseYieldPercent(yieldPercent); + if (yieldValue === null) return; + const y = yieldValue / 100; if (mode === 'weight') { const target = parseFloat(targetWeight) || 0; @@ -399,7 +404,14 @@ const Calculator = () => { } }; - const canCalculate = species && toState; + const yieldIsValid = parseYieldPercent(yieldPercent) !== null; + const canCalculate = species && toState && yieldIsValid; + + // A result computed from different inputs is misleading; clear it on edit. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setResult(null); + }, [mode, cost, targetWeight, yieldPercent, processingCost, weightType]); const handleSubmit = (e) => { e.preventDefault(); @@ -647,10 +659,19 @@ const Calculator = () => { className="form-input pr-10 tabular-nums" placeholder="0" inputMode="decimal" - aria-describedby={yieldRange ? 'calc-yield-hint' : undefined} + aria-invalid={yieldPercent !== '' && !yieldIsValid} + aria-describedby={[ + yieldPercent !== '' && !yieldIsValid ? 'calc-yield-error' : null, + yieldRange ? 'calc-yield-hint' : null, + ].filter(Boolean).join(' ') || undefined} /> + {yieldPercent !== '' && !yieldIsValid && ( + + )} {yieldRange && (

Typical range: {yieldRange[0]}–{yieldRange[1]}% @@ -707,13 +728,13 @@ const Calculator = () => { - {!canCalculate && ( + {!canCalculate && !(species && toState) && (

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

diff --git a/app/src/components/ContributorProfile.jsx b/app/src/components/ContributorProfile.jsx index 7ac041c..098f0eb 100644 --- a/app/src/components/ContributorProfile.jsx +++ b/app/src/components/ContributorProfile.jsx @@ -20,7 +20,7 @@ const ContributorProfile = () => { const loadProfile = async () => { try { const headers = await getAuthHeaders(); - const res = await fetch(apiUrl('/api/contributor/me'), { headers }); + const res = await fetch(apiUrl('/api/contributor'), { headers }); if (res.status === 404) { setLoaded(true); diff --git a/app/src/components/PreviewPublishModal.jsx b/app/src/components/PreviewPublishModal.jsx index 6337cdf..591bae7 100644 --- a/app/src/components/PreviewPublishModal.jsx +++ b/app/src/components/PreviewPublishModal.jsx @@ -10,10 +10,11 @@ const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabi * Props: * calc — the local calc record { species, product, cost, yield, result, createdAt, name } * loading — true while the publish API call is in flight + * error — message shown when publishing failed permanently * onConfirm — called when the user clicks "Publish" * onCancel — called when the user cancels */ -export default function PreviewPublishModal({ calc, loading, onConfirm, onCancel }) { +export default function PreviewPublishModal({ calc, loading, error, onConfirm, onCancel }) { const dialogRef = useRef(null); const triggerRef = useRef(null); @@ -87,6 +88,12 @@ export default function PreviewPublishModal({ calc, loading, onConfirm, onCancel + {error && ( +

+ {error} +

+ )} +