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 && (
+
+ Enter a yield above 0 and up to 100%.
+
+ )}
{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}
+
+ )}
+