Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions api/_lib/importRows.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
paccloud marked this conversation as resolved.
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 = [];
Expand All @@ -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;
Expand All @@ -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.');
Expand Down
35 changes: 28 additions & 7 deletions app/src/components/Calculator.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -183,15 +184,17 @@ 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);

useEffect(() => {
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(() => {});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}
/>
<span aria-hidden="true" className="absolute right-3.5 top-1/2 -translate-y-1/2 text-base text-text-secondary">%</span>
</div>
{yieldPercent !== '' && !yieldIsValid && (
<p id="calc-yield-error" role="alert" className="mt-1.5 text-sm font-medium text-danger">
Enter a yield above 0 and up to 100%.
</p>
)}
{yieldRange && (
<p id="calc-yield-hint" className="mt-1.5 text-sm text-text-secondary">
Typical range: {yieldRange[0]}–{yieldRange[1]}%
Expand Down Expand Up @@ -707,13 +728,13 @@ const Calculator = () => {
<button
type="submit"
disabled={!canCalculate}
aria-describedby={canCalculate ? undefined : 'calc-hint'}
aria-describedby={species && toState ? undefined : 'calc-hint'}
className="btn-primary w-full"
>
<CalcIcon size={20} aria-hidden="true" />
{mode === 'cost' ? 'Calculate cost per pound' : 'Calculate pounds to buy'}
</button>
{!canCalculate && (
{!canCalculate && !(species && toState) && (
<p id="calc-hint" className="mt-2 text-center text-sm text-text-secondary">
Choose a species, what you have, and what you're making to get started.
</p>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/ContributorProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion app/src/components/PreviewPublishModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -87,6 +88,12 @@ export default function PreviewPublishModal({ calc, loading, onConfirm, onCancel
<Row label="Date" value={date} />
</div>

{error && (
<p role="alert" className="mb-3 text-sm text-danger">
{error}
</p>
)}

<div className="flex gap-2">
<button
onClick={onCancel}
Expand Down
28 changes: 23 additions & 5 deletions app/src/context/DataContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
const DataContext = createContext(null);

export function DataProvider({ children }) {
const { user, logout } = useAuth();
const { user, logout, getAuthHeaders } = useAuth();
const [savedCalcs, setSavedCalcs] = useState([]);
const [customYields, setCustomYields] = useState([]);
const [customSpecies, setCustomSpeciesState] = useState({});
Expand All @@ -37,6 +37,7 @@
// null | calc record — non-null while publish preview modal is shown
const [publishPreviewCalc, setPublishPreviewCalc] = useState(null);
const [publishLoading, setPublishLoading] = useState(false);
const [publishError, setPublishError] = useState(null);
// null | { calcs: number, yields: number } — non-null while recovery modal is shown
const [recoveryCounts, setRecoveryCounts] = useState(null);
const [recoveryAssigning, setRecoveryAssigning] = useState(false);
Expand Down Expand Up @@ -161,7 +162,7 @@
}

return { conflictCount: conflicts.length };
}, [repo, coordinator]);

Check warning on line 165 in app/src/context/DataContext.jsx

View workflow job for this annotation

GitHub Actions / Lint · Test · Build

React Hook useCallback has an unnecessary dependency: 'coordinator'. Either exclude it or remove the dependency array

// Detect guest records when user transitions from null → authenticated.
useEffect(() => {
Expand Down Expand Up @@ -384,6 +385,7 @@

// Open the preview modal — no API call yet.
const requestPublish = useCallback((calc) => {
setPublishError(null);
setPublishPreviewCalc(calc);
}, []);

Expand All @@ -392,7 +394,16 @@
if (!publishPreviewCalc?.serverId) return;
setPublishLoading(true);
try {
const authHeaders = await (user?.getAuthHeaders?.() ?? Promise.resolve({}));
let authHeaders;
try {
authHeaders = await getAuthHeaders();
} catch {
// Token refresh failed: the session is ending, so close this
// account-scoped preview rather than leave it open.
setPublishError(null);
setPublishPreviewCalc(null);
return;
}
let succeeded = false;
let transientFailure = false;
try {
Expand All @@ -418,13 +429,19 @@
);
debouncedSync();
}
if (!succeeded && !transientFailure) {
// Permanent failure (e.g. 401/404): keep the modal open and say so.
setPublishError('Could not publish this calculation. Sign in again and retry.');
return;
}
setPublishPreviewCalc(null);
} finally {
setPublishLoading(false);
}
}, [publishPreviewCalc, user, repo, debouncedSync]);
}, [publishPreviewCalc, getAuthHeaders, repo, debouncedSync]);

const cancelPublish = useCallback(() => {
setPublishError(null);
setPublishPreviewCalc(null);
}, []);

Expand All @@ -434,7 +451,7 @@
let succeeded = false;
let transientFailure = false;
try {
const authHeaders = await (user?.getAuthHeaders?.() ?? Promise.resolve({}));
const authHeaders = await getAuthHeaders();
const res = await apiClient.unpublishCalcRaw(calc.serverId, authHeaders);
if (res.ok) {
await repo.updateCalcPublicationState(calc.id, true);
Expand All @@ -456,7 +473,7 @@
);
debouncedSync();
}
}, [user, repo, debouncedSync]);
}, [getAuthHeaders, repo, debouncedSync]);

// ---- Saved Calculations ----

Expand Down Expand Up @@ -582,6 +599,7 @@
<PreviewPublishModal
calc={publishPreviewCalc}
loading={publishLoading}
error={publishError}
onConfirm={confirmPublish}
onCancel={cancelPublish}
/>
Expand Down
44 changes: 44 additions & 0 deletions app/src/lib/__tests__/importRows.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { Buffer } from 'node:buffer';
import {
normalizeYieldRows,
parseImportRows,
upsertImportedYieldRows,
} from '../../../../api/_lib/importRows.js';

Expand Down Expand Up @@ -63,3 +65,45 @@ describe('upsertImportedYieldRows — transaction behavior', () => {
expect(calls).not.toContain('COMMIT');
});
});

describe('parseImportRows — real files end to end', () => {
it('imports an Excel "42%" percent cell as a 42% yield', async () => {
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Yields');
sheet.addRow(['Species', '% Yield', 'Product', 'Source']);
const row = sheet.addRow(['Coho Salmon', 0.42, 'Skinless Fillet', 'Test']);
row.getCell(2).numFmt = '0%';
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());

const parsed = await parseImportRows(buffer, '.xlsx');
const { rows, skippedRows } = normalizeYieldRows(parsed, 'test.xlsx');

expect(skippedRows).toEqual([]);
expect(rows).toHaveLength(1);
expect(rows[0].yield).toBe(42);
});

it('keeps a plain (not percent-formatted) Excel 0.5 as 0.5%, as the app exports it', async () => {
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Yields');
sheet.addRow(['Species', '% Yield', 'Product', 'Source']);
sheet.addRow(['Anchovy', 0.5, 'Fillet', 'Test']);
sheet.addRow(['Pink Salmon', 42, 'Fillet', 'Test']);
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());

const parsed = await parseImportRows(buffer, '.xlsx');
const { rows } = normalizeYieldRows(parsed, 'test.xlsx');

expect(rows.map((r) => r.yield)).toEqual([0.5, 42]);
});

it('keeps a CSV "0.5" yield as 0.5%, not 50%', async () => {
const csv = 'Species,% Yield,Product,Source\nAnchovy,0.5,Fillet,Test\nPink Salmon,42%,Fillet,Test\n';
const parsed = await parseImportRows(Buffer.from(csv), '.csv');
const { rows } = normalizeYieldRows(parsed, 'test.csv');

expect(rows.map((r) => r.yield)).toEqual([0.5, 42]);
});
});
63 changes: 63 additions & 0 deletions app/src/lib/fishDataShape.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Normalizes fish yield data into the shape the Calculator reads:
// conversions[key] = { from, to, yield: number, range: [min, max] | null }.
//
// The bundled FISH_DATA_V3 keys conversions as "From → To" and omits the
// from/to fields; the Neon-backed /api/fish-data response includes them.
// Normalizing both lets the calculator work offline and when the API fails.

const ARROW = ' → ';

function toNumber(value) {
const n = typeof value === 'number' ? value : Number.parseFloat(value);
return Number.isFinite(n) ? n : null;
}

function toRange(range) {
if (Array.isArray(range) && range.length === 2) {
const min = toNumber(range[0]);
const max = toNumber(range[1]);
return min !== null && max !== null ? [min, max] : null;
}
if (typeof range === 'string') {
const [min, max] = range.split('-').map(toNumber);
return min != null && max != null ? [min, max] : null;
}
return null;
}

export function normalizeConversion(key, conv) {
let { from, to } = conv;
if ((!from || !to) && key.includes(ARROW)) {
const [keyFrom, ...rest] = key.split(ARROW);
from = from || keyFrom.trim();
to = to || rest.join(ARROW).trim();
}
return { ...conv, from, to, yield: toNumber(conv.yield), range: toRange(conv.range) };
}

export function withConversionStates(fishData) {
const out = {};
for (const [species, data] of Object.entries(fishData || {})) {
const conversions = {};
for (const [key, conv] of Object.entries(data?.conversions || {})) {
conversions[key] = normalizeConversion(key, conv || {});
}
out[species] = { ...data, conversions };
}
return out;
}

// True when at least one conversion has a usable from/to pair and yield.
export function hasUsableConversions(fishData) {
return Object.values(fishData || {}).some((data) =>
Object.values(data?.conversions || {}).some(
(conv) => conv.from && conv.to && conv.yield !== null && conv.yield > 0
)
);
}

// A yield percentage is usable when it is a finite number in (0, 100].
export function parseYieldPercent(value) {
const n = toNumber(value);
return n !== null && n > 0 && n <= 100 ? n : null;
}
Loading
Loading