From 22dbe218fbc21ad277d7435e02c0afe439d546c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:39:21 +0000 Subject: [PATCH 1/3] fix: make calculator, publish, profile and Excel import work - Calculator: derive from/to states from bundled FISH_DATA_V3 keys so it works offline and when /api/fish-data fails; only replace bundled data with a usable API response. Reject yields outside (0, 100] instead of silently treating 0 as 100%, and clear stale results on input edits. - Publish/unpublish: use getAuthHeaders from useAuth (user had no such method, so requests went out unauthenticated and failed silently); show an error in the preview modal on permanent failure. - Contributor profile: call /api/contributor (the /me path only exists in Express) and accept Postgres booleans for show_on_page. - Import: keep numeric XLSX cells so a "42%" cell imports as 42, not 0.42; CSV "0.5" stays 0.5% in both backends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- api/_lib/importRows.js | 22 ++++++-- app/src/components/Calculator.jsx | 25 +++++++-- app/src/components/ContributorProfile.jsx | 5 +- app/src/components/PreviewPublishModal.jsx | 9 +++- app/src/context/DataContext.jsx | 19 +++++-- app/src/lib/__tests__/importRows.test.js | 29 ++++++++++ app/src/lib/fishDataShape.js | 63 ++++++++++++++++++++++ app/src/lib/fishDataShape.test.js | 51 ++++++++++++++++++ server/importRows.js | 23 ++++++-- 9 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 app/src/lib/fishDataShape.js create mode 100644 app/src/lib/fishDataShape.test.js diff --git a/api/_lib/importRows.js b/api/_lib/importRows.js index b206f03..509aaa0 100644 --- a/api/_lib/importRows.js +++ b/api/_lib/importRows.js @@ -65,7 +65,21 @@ function parseYieldPercent(value) { return Number.parseFloat(text); } -function rowsFromWorksheet(worksheet) { +// XLSX keeps numeric cells as numbers so normalizeYieldRows can recognize +// percentage cells stored as fractions (a "42%" cell is stored as 0.42). +// CSV cells stay strings: ExcelJS parses CSV numbers too, but "0.5" in a CSV +// means 0.5%, not 50%. +function cellValue(value, keepNumbers) { + if (keepNumbers) { + 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, { keepNumbers = false } = {}) { const rows = []; const headerRow = worksheet.getRow(1); const headers = []; @@ -83,9 +97,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).value, keepNumbers); }); - 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 +118,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, { keepNumbers: 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 e2dc05e..8c8a0fb 100644 --- a/app/src/components/Calculator.jsx +++ b/app/src/components/Calculator.jsx @@ -3,6 +3,7 @@ import { ACRONYMS, FISH_DATA_V3, PROFILES_DATA } from '../data/fish_data_v3'; import { Info, Calculator as CalcIcon, Save, HelpCircle, Download, ChevronRight } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { apiUrl } from '../config/api'; +import { withConversionStates, hasUsableConversions, parseYieldPercent } from '../lib/fishDataShape'; const Tooltip = ({ text, children }) => { const [show, setShow] = useState(false); @@ -93,7 +94,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); @@ -101,7 +102,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(() => {}); @@ -218,7 +221,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; @@ -300,7 +305,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]); return (
@@ -514,6 +526,11 @@ const Calculator = () => { /> %
+ {yieldPercent !== '' && !yieldIsValid && ( +

+ Enter a yield between 0 and 100%. +

+ )} {yieldRange && (

Reported range: {yieldRange[0]}–{yieldRange[1]}% diff --git a/app/src/components/ContributorProfile.jsx b/app/src/components/ContributorProfile.jsx index 80d13db..e35160d 100644 --- a/app/src/components/ContributorProfile.jsx +++ b/app/src/components/ContributorProfile.jsx @@ -21,7 +21,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) return; @@ -51,7 +51,8 @@ const ContributorProfile = () => { display_name: data.display_name || '', organization: data.organization || '', bio: data.bio || '', - show_on_page: data.show_on_page === 1 + // Postgres returns a boolean, SQLite returns 0/1. + show_on_page: data.show_on_page === true || data.show_on_page === 1 }); } } catch (err) { diff --git a/app/src/components/PreviewPublishModal.jsx b/app/src/components/PreviewPublishModal.jsx index 1801d65..f95dc99 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} +

+ )} +