Skip to content
Merged
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
992 changes: 508 additions & 484 deletions app/src/components/Calculator.jsx

Large diffs are not rendered by default.

41 changes: 24 additions & 17 deletions app/src/lib/calcEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*/

/**
Expand All @@ -92,6 +98,7 @@ export function calculate(inputs) {
weightType = 'incoming',
coldStorage = 0,
shipping = 0,
shippingWeightType = 'outgoing',
showTimeTracking = false,
processingSteps = [],
showEconomyOfScale = false,
Expand All @@ -109,6 +116,7 @@ export function calculate(inputs) {
return {
result: y > 0 ? target / y : 0,
appliedDiscount: 0,
breakdown: null,
};
}

Expand All @@ -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) {
Expand All @@ -159,5 +166,5 @@ export function calculate(inputs) {
baseRes = baseRes * (1 - appliedDiscount / 100);
}

return { result: baseRes, appliedDiscount };
return { result: baseRes, appliedDiscount, breakdown };
}
72 changes: 72 additions & 0 deletions app/src/lib/calcEngine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions app/src/lib/numberInput.js
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions app/src/lib/numberInput.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 23 additions & 6 deletions docs/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/design-mockups/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading