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
55 changes: 27 additions & 28 deletions app/src/components/Calculator.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { Link } from 'react-router-dom';
import { ACRONYMS, FISH_DATA_V3, PROFILES_DATA } from '../data/fish_data_v3';
import { Calculator as CalcIcon, Save, HelpCircle, Download, ChevronDown } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useData } from '../context/DataContext';
import { apiUrl } from '../config/api';
import { isAppReadOnly } from '../config/move';
import { calculate } from '../lib/calcEngine';
import { parseAmount } from '../lib/numberInput';
import { withConversionStates, hasUsableConversions, parseYieldPercent } from '../lib/fishDataShape';
import { mergeFishData } from '../lib/fishDataMerge';

/**
* Help bubble that works for mouse (hover), keyboard (focus) and touch (tap).
Expand Down Expand Up @@ -230,9 +232,12 @@ const StepHeading = ({ number, children, id }) => (
);

const TO_LIMIT = 6;
const NO_YIELDS = [];

const Calculator = () => {
const { user, getAuthHeaders } = useAuth();
// Custom yields kept on this device and synced when there is signal, so they work offline too
const { customYields, refreshCustomYields } = useData();
const [mode, setMode] = useState('cost');
const [targetWeight, setTargetWeight] = useState('');
const [species, setSpecies] = useState('');
Expand All @@ -250,7 +255,6 @@ const Calculator = () => {
const [announcement, setAnnouncement] = useState('');
const dockRef = useRef(null);

const [customData, setCustomData] = useState({});
const [_history, setHistory] = useState([]);
const [publicHistory, setPublicHistory] = useState([]);

Expand Down Expand Up @@ -280,44 +284,26 @@ const Calculator = () => {
useEffect(() => {
if (user) {
getAuthHeaders().then(headers => {
fetch(apiUrl('/api/user-data'), { headers })
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
const mapped = {};
data.forEach(item => {
if (!mapped[item.species]) mapped[item.species] = { conversions: {} };
mapped[item.species].conversions[`Custom: ${item.product}`] = {
yield: parseFloat(item.yield),
from: 'Custom',
to: item.product
};
});
setCustomData(mapped);
}
})
.catch(() => {});

fetch(apiUrl('/api/saved-calcs'), { headers })
.then(res => res.json())
.then(data => setHistory(data))
.catch(() => {});
});
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setCustomData({});
setHistory([]);
}
}, [user, getAuthHeaders]);

const combinedData = useMemo(() => {
const merged = { ...fishData };
Object.keys(customData).forEach(sp => {
if (!merged[sp]) merged[sp] = customData[sp];
else merged[sp] = { ...merged[sp], conversions: { ...merged[sp].conversions, ...customData[sp].conversions } };
});
return merged;
}, [fishData, customData]);
// The provider pulls once when it loads; opening the calculator again pulls too, so a yield
// edited on another device shows up here as it did when this page fetched its own copy
useEffect(() => {
refreshCustomYields();
}, [refreshCustomYields]);

// Only a signed-in person's own yields: the guest scope can hold records no one here owns
const myYields = user ? customYields : NO_YIELDS;
const combinedData = useMemo(() => mergeFishData(fishData, myYields), [fishData, myYields]);

const speciesList = Object.keys(combinedData).sort();

Expand All @@ -344,6 +330,19 @@ const Calculator = () => {
);
}, [species, fromState, toState, combinedData]);

// When a sync changes the chosen conversion (edited or deleted on another device), follow it,
// unless the yield was typed in by hand
const conversionYield = currentConversion ? String(currentConversion.yield) : null;
const [followedYield, setFollowedYield] = useState(conversionYield);
if (conversionYield !== followedYield) {
setFollowedYield(conversionYield);
if (conversionYield === null && toState) {
setToState(''); setYieldPercent('');
} else if (conversionYield !== null && yieldPercent === followedYield) {
setYieldPercent(conversionYield);
}
}

const profile = species ? profilesData[species] : null;
const scientificName = species && combinedData[species] ? combinedData[species].scientific_name : null;
const yieldRange = currentConversion?.range || null;
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/DataManagement.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ const DataManagement = () => {
e.preventDefault();
try {
if (editingId) {
await updateYield(editingId, {
const updated = await updateYield(editingId, {
species: formData.species,
product: formData.product,
yield: parseFloat(formData.yield),
source: formData.source,
});
if (!updated) {
// Deleted on another device while open here: keep what was typed and let them add it back
setEditingId(null);
setStatus({ type: 'error', message: 'This yield was deleted on another device. Save to add it back.' });
return;
}
setStatus({ type: 'success', message: 'Updated successfully!' });
} else {
await addYield({
Expand Down
36 changes: 7 additions & 29 deletions app/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useLayoutEffect, useEffect } from 'react';
import { apiUrl } from '../config/api';
import { getAuthHeaders as getAuthHeadersFn } from '../lib/authHeaders';
import { decodeJwtPayload, legacyUserFromToken } from '../lib/legacyJwt';
import {
clearFirebaseSession,
createGoogleAuthUri,
Expand All @@ -27,23 +28,6 @@ const defaultAuthApi = {
signUpWithEmailPassword,
};

function decodeBase64UrlJson(value) {
try {
let base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = base64.length % 4;
if (padding) {
base64 += '='.repeat(4 - padding);
}

const binary = globalThis.atob(base64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
} catch {
return null;
}
}

function loadLegacyJwtSession(storage = globalThis.localStorage) {
if (!storage || typeof storage.getItem !== 'function') {
return null;
Expand All @@ -54,8 +38,7 @@ function loadLegacyJwtSession(storage = globalThis.localStorage) {
return null;
}

const [, encodedPayload] = storedToken.split('.');
const payload = encodedPayload ? decodeBase64UrlJson(encodedPayload) : null;
const payload = decodeJwtPayload(storedToken);
if (!payload?.username) {
storage.removeItem?.('token');
return null;
Expand All @@ -69,12 +52,7 @@ function loadLegacyJwtSession(storage = globalThis.localStorage) {

return {
token: storedToken,
user: {
id: payload.id,
username: payload.username,
email: payload.email || null,
authProvider: 'password',
},
user: legacyUserFromToken(storedToken),
};
}

Expand Down Expand Up @@ -125,8 +103,7 @@ export const AuthProvider = ({ children, authApi = defaultAuthApi }) => {
// as logged in while all protected requests return 401.
useEffect(() => {
if (!token) return;
const [, encodedPayload] = token.split('.');
const payload = encodedPayload ? decodeBase64UrlJson(encodedPayload) : null;
const payload = decodeJwtPayload(token);
if (!payload?.exp) return;
let timerId;
function scheduleLogout() {
Expand Down Expand Up @@ -183,7 +160,7 @@ export const AuthProvider = ({ children, authApi = defaultAuthApi }) => {
authApi.clearFirebaseSession();
globalThis.localStorage?.setItem('token', legacyData.token);
setToken(legacyData.token);
setUser({ username: legacyData.username, authProvider: 'password' });
setUser(legacyUserFromToken(legacyData.token) ?? { username: legacyData.username, authProvider: 'password' });
return true;
}
const response = await globalThis.fetch(apiUrl('/api/login'), {
Expand All @@ -201,7 +178,8 @@ export const AuthProvider = ({ children, authApi = defaultAuthApi }) => {
authApi.clearFirebaseSession();
globalThis.localStorage?.setItem('token', data.token);
setToken(data.token);
setUser({ username: data.username, authProvider: 'password' });
// The id in the token gives the account its own storage scope (see legacyUserFromToken)
setUser(legacyUserFromToken(data.token) ?? { username: data.username, authProvider: 'password' });
return true;
};

Expand Down
45 changes: 38 additions & 7 deletions app/src/context/DataContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
const [customSpecies, setCustomSpeciesState] = useState({});
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [dataLoaded, setDataLoaded] = useState(false);
const [storageFailed, setStorageFailed] = useState(false);
// 'idle' | 'syncing' | 'synced' | 'offline' | 'pending' | 'error' | 'conflict'
const [syncStatus, setSyncStatus] = useState('idle');
const [syncError, setSyncError] = useState(null); // null | 'auth' | 'network'
Expand Down Expand Up @@ -91,12 +92,19 @@
loadedScopeRef.current = null;
async function loadData() {
setDataLoaded(false);
const [calcs, yields, species, conflicts] = await Promise.all([
repo.getCalcs(),
repo.getYields(),
getCustomSpecies(),
repo.getConflictedYields(),
]);
let calcs, yields, species, conflicts;
try {
[calcs, yields, species, conflicts] = await Promise.all([
repo.getCalcs(),
repo.getYields(),
getCustomSpecies(),
repo.getConflictedYields(),
]);
} catch {
// Site storage blocked or broken: nothing can be kept on this device.
if (!cancelled) setStorageFailed(true);
return;
}
if (!cancelled) {
setSavedCalcs(calcs);
setCustomYields(yields);
Expand All @@ -110,6 +118,21 @@
return () => { cancelled = true; };
}, [repo, scope]);

// Without on-device storage there is nothing for the sync to fill, so read the account's custom
// yields from the server instead and hold them in memory, tagged with the account they belong to.
const [serverYields, setServerYields] = useState({ uid: null, rows: [] });
const [serverYieldsWanted, setServerYieldsWanted] = useState(0); // bumped to fetch them again
useEffect(() => {
if (!storageFailed || !uid || !isOnline) return;
Comment thread
paccloud marked this conversation as resolved.
let cancelled = false;
getAuthHeaders()
.then((headers) => apiClient.listUserDataRaw(headers))
.then((res) => (res.ok ? res.json() : null))
.then((rows) => { if (!cancelled && Array.isArray(rows)) setServerYields({ uid, rows }); })
.catch(() => {});
return () => { cancelled = true; };
}, [storageFailed, uid, isOnline, getAuthHeaders, serverYieldsWanted]);

// One-time legacy migration + recovery check. Runs once per session.
useEffect(() => {
if (recoveryCheckedRef.current) return;
Expand Down Expand Up @@ -164,7 +187,7 @@
}

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

Check warning on line 190 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 @@ -626,13 +649,20 @@
return triggerSync();
}, [user, triggerSync]);

// Get the account's latest custom yields: through the sync, or straight from the server when
// this device can't store them. The calculator calls this each time it opens.
const refreshCustomYields = useCallback(() => {
if (storageFailed) setServerYieldsWanted((n) => n + 1);
else if (dataLoaded) retrySync();
}, [storageFailed, dataLoaded, retrySync]);

// Gate account data so consumers never see the previous scope's records
// during the render cycle between a uid change and the clearing effect.
const scopeReady = loadedScopeRef.current === scope;

const value = {
savedCalcs: scopeReady ? savedCalcs : [],
customYields: scopeReady ? customYields : [],
customYields: scopeReady ? customYields : (storageFailed && serverYields.uid === uid ? serverYields.rows : []),
customSpecies,
conflictedYields: scopeReady ? conflictedYields : [],
isOnline,
Expand All @@ -648,6 +678,7 @@
removeYield,
updateCustomSpecies,
retrySync,
refreshCustomYields,
signOut,
requestPublish,
confirmPublish,
Expand Down
37 changes: 37 additions & 0 deletions app/src/lib/legacyJwt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Legacy password sessions (retired by ADR 0001) keep the account in a JWT. The client only
// reads it for display and for choosing where data is stored; the server verifies every token.

function decodeBase64UrlJson(value) {
try {
let base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = base64.length % 4;
if (padding) {
base64 += '='.repeat(4 - padding);
}

const binary = globalThis.atob(base64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
} catch {
return null;
}
}

export function decodeJwtPayload(token) {
const [, encodedPayload] = String(token ?? '').split('.');
return encodedPayload ? decodeBase64UrlJson(encodedPayload) : null;
}

// The signed-in user a legacy JWT stands for. The id matters: DataContext keys the account's
// on-device data by it, so without one the account's synced yields would land in the guest scope.
export function legacyUserFromToken(token) {
const payload = decodeJwtPayload(token);
if (!payload?.username) return null;
return {
id: payload.id,
username: payload.username,
email: payload.email || null,
authProvider: 'password',
};
}
34 changes: 34 additions & 0 deletions app/src/lib/legacyJwt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Buffer } from 'node:buffer';
import { describe, expect, it } from 'vitest';
import { decodeJwtPayload, legacyUserFromToken } from './legacyJwt';

const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const jwt = (payload) => `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.signature`;

describe('decodeJwtPayload', () => {
it('reads the payload of a JWT', () => {
expect(decodeJwtPayload(jwt({ id: 7, username: 'skipper' }))).toEqual({ id: 7, username: 'skipper' });
});

it('returns null for anything that is not a JWT', () => {
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
expect(decodeJwtPayload('a.%%%.c')).toBeNull();
expect(decodeJwtPayload(null)).toBeNull();
});
});

describe('legacyUserFromToken', () => {
it('includes the account id, so the account gets its own storage scope', () => {
expect(legacyUserFromToken(jwt({ id: 7, username: 'skipper', exp: 9999999999 }))).toEqual({
id: 7,
username: 'skipper',
email: null,
authProvider: 'password',
});
});

it('returns null when the token names no user', () => {
expect(legacyUserFromToken(jwt({ id: 7 }))).toBeNull();
expect(legacyUserFromToken('garbage')).toBeNull();
});
});
Loading
Loading