From 2aff57ee7faed90be3eb8073a6046119da8ba0e8 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Tue, 7 Apr 2026 18:23:28 -0400 Subject: [PATCH] visual improvements --- backend/package.json | 2 +- backend/src/app.ts | 123 +++++- frontend/Dockerfile.dev | 1 - frontend/src/App.tsx | 956 +++++++++++++++++++++++++++------------- frontend/src/index.css | 249 +++++++++-- 5 files changed, 961 insertions(+), 370 deletions(-) diff --git a/backend/package.json b/backend/package.json index aeeac43..6041067 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "node --import tsx src/app.ts", + "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js" }, diff --git a/backend/src/app.ts b/backend/src/app.ts index 77201af..504b123 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -39,6 +39,11 @@ const allowedOrigins = Array.from( ); const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/); +const photoDataUrlSchema = z + .string() + .regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/) + .max(1_500_000); const birdSchema = z.object({ name: z.string().trim().min(1).max(120), @@ -46,6 +51,8 @@ const birdSchema = z.object({ species: z.string().trim().min(1).max(120), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), + chartColor: chartColorSchema.optional(), + photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')), }); const weightSchema = z.object({ @@ -68,6 +75,8 @@ type BirdRow = { species: string; date_of_birth: string | null; gotcha_day: string | null; + chart_color: string; + photo_data_url: string | null; created_at: string; latest_weight_grams: string | null; latest_recorded_on: string | null; @@ -111,7 +120,7 @@ app.use( legacyHeaders: false, }), ); -app.use(express.json({ limit: '300kb' })); +app.use(express.json({ limit: '2mb' })); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); const emptyToNull = (value?: string) => { @@ -126,11 +135,27 @@ const normalizeBird = (row: BirdRow) => ({ species: row.species, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, + chartColor: row.chart_color, + photoDataUrl: row.photo_data_url, createdAt: row.created_at, latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, latestRecordedOn: row.latest_recorded_on, }); +const birdSelectFields = ` + birds.id, + birds.name, + birds.tag_id, + birds.species, + birds.date_of_birth::text, + birds.gotcha_day::text, + birds.chart_color, + birds.photo_data_url, + birds.created_at, + latest.weight_grams AS latest_weight_grams, + latest.recorded_on::text AS latest_recorded_on +`; + const normalizeWeight = (row: WeightRow) => ({ id: row.id, birdId: row.bird_id, @@ -159,12 +184,16 @@ const ensureSchema = async () => { species VARCHAR(120) NOT NULL, date_of_birth DATE, gotcha_day DATE, + chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', + photo_data_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ALTER TABLE birds ADD COLUMN IF NOT EXISTS date_of_birth DATE, - ADD COLUMN IF NOT EXISTS gotcha_day DATE; + ADD COLUMN IF NOT EXISTS gotcha_day DATE, + ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', + ADD COLUMN IF NOT EXISTS photo_data_url TEXT; CREATE TABLE IF NOT EXISTS weight_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -197,15 +226,7 @@ const ensureSchema = async () => { const getBirdById = async (birdId: string) => { const result = await pool.query( `SELECT - birds.id, - birds.name, - birds.tag_id, - birds.species, - birds.date_of_birth::text, - birds.gotcha_day::text, - birds.created_at, - latest.weight_grams AS latest_weight_grams, - latest.recorded_on::text AS latest_recorded_on + ${birdSelectFields} FROM birds LEFT JOIN LATERAL ( SELECT weight_grams, recorded_on @@ -229,15 +250,7 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) = try { const result = await pool.query(` SELECT - birds.id, - birds.name, - birds.tag_id, - birds.species, - birds.date_of_birth::text, - birds.gotcha_day::text, - birds.created_at, - latest.weight_grams AS latest_weight_grams, - latest.recorded_on::text AS latest_recorded_on + ${birdSelectFields} FROM birds LEFT JOIN LATERAL ( SELECT weight_grams, recorded_on @@ -265,15 +278,17 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) = try { const result = await pool.query( - `INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ parsed.data.name, parsed.data.tagId, parsed.data.species, emptyToNull(parsed.data.dateOfBirth), emptyToNull(parsed.data.gotchaDay), + parsed.data.chartColor ?? '#cb3a35', + emptyToNull(parsed.data.photoDataUrl), ], ); @@ -288,6 +303,68 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) = } }); +app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => { + const parsed = birdSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() }); + return; + } + + try { + const result = await pool.query( + `UPDATE birds + SET name = $2, + tag_id = $3, + species = $4, + date_of_birth = $5, + gotcha_day = $6, + chart_color = $7, + photo_data_url = $8 + WHERE id = $1 + RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, + ( + SELECT weight_grams::text + FROM weight_records + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_weight_grams, + ( + SELECT recorded_on::text + FROM weight_records + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_recorded_on`, + [ + req.params.birdId, + parsed.data.name, + parsed.data.tagId, + parsed.data.species, + emptyToNull(parsed.data.dateOfBirth), + emptyToNull(parsed.data.gotchaDay), + parsed.data.chartColor ?? '#cb3a35', + emptyToNull(parsed.data.photoDataUrl), + ], + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + res.json({ bird: normalizeBird(result.rows[0]) }); + } catch (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { + res.status(409).json({ error: 'That tag ID is already in use.' }); + return; + } + + next(error); + } +}); + app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => { try { const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]); diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 5845ca9..1e1a14a 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -5,7 +5,6 @@ RUN npm install COPY tsconfig*.json ./ COPY vite.config.ts ./ COPY index.html ./ -COPY public ./public COPY src ./src EXPOSE 3000 CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9313b1..5edf0ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ type Bird = { species: string; dateOfBirth: string | null; gotchaDay: string | null; + chartColor: string; + photoDataUrl: string | null; createdAt: string; latestWeightGrams: number | null; latestRecordedOn: string | null; @@ -29,9 +31,40 @@ type VetVisit = { notes: string | null; }; +type BirdFormState = { + name: string; + tagId: string; + species: string; + dateOfBirth: string; + gotchaDay: string; + chartColor: string; + photoDataUrl: string; +}; + type AppPage = 'overview' | 'flock' | 'settings'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; +const emptyBirdForm: BirdFormState = { + name: '', + tagId: '', + species: '', + dateOfBirth: '', + gotchaDay: '', + chartColor: '#cb3a35', + photoDataUrl: '', +}; + +const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name)); + +const toBirdForm = (bird: Bird): BirdFormState => ({ + name: bird.name, + tagId: bird.tagId, + species: bird.species, + dateOfBirth: bird.dateOfBirth ?? '', + gotchaDay: bird.gotchaDay ?? '', + chartColor: bird.chartColor, + photoDataUrl: bird.photoDataUrl ?? '', +}); const formatDate = (value: string | null) => { if (!value) { @@ -57,6 +90,45 @@ const formatShortDate = (value: string | null) => { }; const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); +const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); +const OVERVIEW_WIDTH = 520; +const OVERVIEW_HEIGHT = 220; +const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 }; + +const readJsonSafely = async (response: Response): Promise => { + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('application/json')) { + return null; + } + + try { + return (await response.json()) as T; + } catch { + return null; + } +}; + +const readErrorMessage = async (response: Response, fallback: string) => { + const json = await readJsonSafely<{ error?: string }>(response); + + if (json?.error) { + return json.error; + } + + const text = await response.text(); + const trimmed = text.trim(); + + if (!trimmed) { + return fallback; + } + + if (trimmed.startsWith(' { if (!points.length) { @@ -95,30 +167,44 @@ const chartDots = (points: WeightRecord[], width = 520, height = 180) => { })); }; -const birdLineStyles = [ - { stroke: '#cb3a35' }, - { stroke: '#238a5a' }, - { stroke: '#2769b3' }, - { stroke: '#f0b63f' }, - { stroke: '#2f8f98' }, -]; +const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => { + const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right; + const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom; + const weightSpread = Math.max(maxWeight - minWeight, 1); + const startMs = startDate.getTime(); + const endMs = endDate.getTime(); + const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); + + return points.map((point) => { + const pointTime = parseDateValue(point.recordedOn).getTime(); + const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth; + const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight; + + return { + id: point.id, + x, + y, + label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`, + }; + }); +}; + +const toOverviewPath = (points: { x: number; y: number }[]) => + points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); function App() { const [activePage, setActivePage] = useState('overview'); const [birds, setBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); + const [editingBirdId, setEditingBirdId] = useState(''); const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [birdForm, setBirdForm] = useState({ - name: '', - tagId: '', - species: '', - dateOfBirth: '', - gotchaDay: '', - }); + const [birdForm, setBirdForm] = useState(emptyBirdForm); + const [birdPhotoName, setBirdPhotoName] = useState(''); + const [savingBird, setSavingBird] = useState(false); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), @@ -143,6 +229,10 @@ function App() { () => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null, [birds, selectedBirdId], ); + const editingBird = useMemo( + () => birds.find((bird) => bird.id === editingBirdId) ?? null, + [birds, editingBirdId], + ); const totalWeightEntries = useMemo( () => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0), @@ -172,12 +262,66 @@ function App() { : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`; }, [weights]); + const overviewChart = useMemo(() => { + const plottedBirds = birds + .map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] })) + .filter((entry) => entry.weights.length > 0); + + const endDate = new Date(); + endDate.setHours(0, 0, 0, 0); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 29); + + if (!plottedBirds.length) { + return { + plottedBirds, + series: [], + xTicks: [ + { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left }, + { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right }, + ], + yTicks: [], + }; + } + + const allWeights = plottedBirds.flatMap((entry) => entry.weights.map((weight) => weight.weightGrams)); + const rawMinWeight = Math.min(...allWeights); + const rawMaxWeight = Math.max(...allWeights); + const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); + const minWeight = Math.max(0, rawMinWeight - weightPadding); + const maxWeight = rawMaxWeight + weightPadding; + const midWeight = minWeight + (maxWeight - minWeight) / 2; + + return { + plottedBirds, + series: plottedBirds.map(({ bird, weights: birdWeights }) => ({ + bird, + points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate), + })), + xTicks: [ + { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left }, + { label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 }, + { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right }, + ], + yTicks: [ + { label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top }, + { label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 }, + { label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom }, + ], + }; + }, [allBirdWeights, birds]); + useEffect(() => { const loadBirds = async () => { try { setLoading(true); const response = await fetch(`${apiBaseUrl}/birds`); - const data = await response.json(); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load flock members.')); + } + + const data = (await readJsonSafely<{ birds?: Bird[] }>(response)) ?? {}; const nextBirds = data.birds ?? []; setBirds(nextBirds); @@ -205,8 +349,13 @@ function App() { fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`), fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`), ]); - const weightsData = await weightsResponse.json(); - const visitsData = await visitsResponse.json(); + + if (!weightsResponse.ok || !visitsResponse.ok) { + throw new Error('Unable to load flock member details.'); + } + + const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {}; + const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; setWeights(weightsData.weights ?? []); setVetVisits(visitsData.vetVisits ?? []); @@ -229,7 +378,12 @@ function App() { const responses = await Promise.all( birds.map(async (bird) => { const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`); - const data = await response.json(); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load overview weights.')); + } + + const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {}; return [bird.id, (data.weights ?? []) as WeightRecord[]] as const; }), ); @@ -243,35 +397,117 @@ function App() { void loadAllBirdWeights(); }, [birds]); + useEffect(() => { + if (!editingBirdId) { + return; + } + + if (!editingBird) { + setEditingBirdId(''); + setBirdForm(emptyBirdForm); + setBirdPhotoName(''); + return; + } + + setBirdForm(toBirdForm(editingBird)); + setBirdPhotoName(''); + }, [editingBird, editingBirdId]); + + const startCreateBird = () => { + setEditingBirdId(''); + setBirdForm(emptyBirdForm); + setBirdPhotoName(''); + setError(''); + setActivePage('settings'); + }; + + const startEditBird = (bird: Bird) => { + setSelectedBirdId(bird.id); + setEditingBirdId(bird.id); + setBirdForm(toBirdForm(bird)); + setBirdPhotoName(''); + setError(''); + setActivePage('settings'); + }; + + const handleBirdPhotoChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + if (file.size > 900_000) { + setError('Photo is too large. Please choose an image under 900 KB.'); + event.target.value = ''; + return; + } + + try { + const photoDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(new Error('Unable to read that photo.')); + reader.readAsDataURL(file); + }); + + setBirdForm((current) => ({ ...current, photoDataUrl })); + setBirdPhotoName(file.name); + setError(''); + } catch (photoError) { + setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.'); + } finally { + event.target.value = ''; + } + }; + + const handleRemovePhoto = () => { + setBirdForm((current) => ({ ...current, photoDataUrl: '' })); + setBirdPhotoName(''); + }; + const handleBirdSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); + setSavingBird(true); + + const isEditing = Boolean(editingBirdId); + const endpoint = isEditing ? `${apiBaseUrl}/birds/${editingBirdId}` : `${apiBaseUrl}/birds`; + const method = isEditing ? 'PUT' : 'POST'; try { - const response = await fetch(`${apiBaseUrl}/birds`, { - method: 'POST', + const response = await fetch(endpoint, { + method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(birdForm), }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error ?? 'Unable to create flock member.'); + throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`)); } - const data = await response.json(); - setBirds((current) => [...current, data.bird].sort((left, right) => left.name.localeCompare(right.name))); - setSelectedBirdId(data.bird.id); - setBirdForm({ - name: '', - tagId: '', - species: '', - dateOfBirth: '', - gotchaDay: '', + const data = await readJsonSafely<{ bird: Bird }>(response); + if (!data?.bird) { + throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`); + } + const savedBird = data.bird as Bird; + + setBirds((current) => { + if (isEditing) { + return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird))); + } + + return sortBirdsByName([...current, savedBird]); }); - setActivePage('flock'); + setSelectedBirdId(savedBird.id); + setEditingBirdId(savedBird.id); + setBirdForm(toBirdForm(savedBird)); + setBirdPhotoName(''); + setActivePage(isEditing ? 'settings' : 'flock'); } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : 'Unable to create flock member.'); + setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.'); + } finally { + setSavingBird(false); } }; @@ -296,11 +532,13 @@ function App() { }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error ?? 'Unable to save weight.'); + throw new Error(await readErrorMessage(response, 'Unable to save weight.')); } - const data = await response.json(); + const data = await readJsonSafely<{ weight: WeightRecord }>(response); + if (!data?.weight) { + throw new Error('Unable to save weight.'); + } const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)); setWeights(nextWeights); @@ -346,11 +584,13 @@ function App() { }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error ?? 'Unable to save vet visit.'); + throw new Error(await readErrorMessage(response, 'Unable to save vet visit.')); } - const data = await response.json(); + const data = await readJsonSafely<{ vetVisit: VetVisit }>(response); + if (!data?.vetVisit) { + throw new Error('Unable to save vet visit.'); + } setVetVisits((current) => [data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)), ); @@ -387,8 +627,7 @@ function App() { }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error ?? 'Unable to remove flock member.'); + throw new Error(await readErrorMessage(response, 'Unable to remove flock member.')); } const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id); @@ -401,6 +640,12 @@ function App() { setSelectedBirdId(nextBirds[0]?.id ?? ''); setWeights([]); setVetVisits([]); + + if (editingBirdId === selectedBird.id) { + setEditingBirdId(''); + setBirdForm(emptyBirdForm); + setBirdPhotoName(''); + } } catch (removeError) { setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.'); } finally { @@ -435,42 +680,50 @@ function App() { return (
-
+ + +
+
+
+

Dashboard

+

FlockPal dashboard

- -
-
- {birds.length} - Flock members -
-
- {totalWeightEntries} - Weight records -
-
- {selectedBird ? formatWeight(selectedBird.latestWeightGrams) : 'Pending'} - Selected member -
-
-
+
+
+ {birds.length} + Flock members +
+
+ {totalWeightEntries} + Weight records +
+
+ {selectedBird ? formatWeight(selectedBird.latestWeightGrams) : 'Pending'} + Selected member +
+
+
- {error ?

{error}

: null} + {error ?

{error}

: null} - {activePage === 'overview' ? ( -
+ {activePage === 'overview' ? ( +
@@ -482,42 +735,54 @@ function App() {
- {birds.map((bird, index) => { - const birdWeights = allBirdWeights[bird.id] ?? []; - const style = birdLineStyles[index % birdLineStyles.length]; - const dots = chartDots(birdWeights, 520, 220); - - if (!birdWeights.length) { - return null; - } - - return ( - - - {dots.map((dot) => ( - - {`${bird.name}: ${dot.label}`} - - ))} - - ); - })} + {overviewChart.yTicks.map((tick) => ( + + + + {tick.label} + + + ))} + + {overviewChart.xTicks.map((tick) => ( + + {tick.label} + + ))} + {overviewChart.series.map(({ bird, points }) => ( + + {points.length > 1 ? ( + + ) : null} + {points.map((point) => ( + + {`${bird.name}: ${point.label}`} + + ))} + + ))}
- {birds.map((bird, index) => { - const style = birdLineStyles[index % birdLineStyles.length]; - const birdWeights = allBirdWeights[bird.id] ?? []; - + {overviewChart.plottedBirds.map(({ bird }) => { return (
- +
{bird.name} - - {bird.species} • {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'} -
); @@ -571,228 +836,285 @@ function App() {
-
- ) : null} +
+ ) : null} - {activePage === 'flock' ? ( - <> + {activePage === 'flock' ? (
- -
-
-
-

Flock member

-

{selectedBird ? selectedBird.name : 'Choose a flock member'}

-
- {selectedBird ? ( +
+
+
+

Flock member

+

{selectedBird ? selectedBird.name : 'Choose a flock member'}

+
+ {selectedBird ? ( +
+ - ) : null} -
+
+ ) : null} +
- {selectedBird ? ( - <> -
-
- Name - {selectedBird.name} -
-
- Band ID - {selectedBird.tagId} -
-
- DOB - {formatDate(selectedBird.dateOfBirth)} -
-
- Gotcha day - {formatDate(selectedBird.gotchaDay)} -
-
- Species - {selectedBird.species} -
-
- Latest weight - {formatWeight(selectedBird.latestWeightGrams)} -
+ {selectedBird ? ( + <> +
+ {selectedBird.photoDataUrl ? ( + {`${selectedBird.name}`} + ) : ( + + )} +
+

Profile

+

{selectedBird.name}

+

+ {selectedBird.species} • Band {selectedBird.tagId} +

+

Added {formatDate(selectedBird.createdAt.slice(0, 10))}

+
-
-
-
-
-

Weight

-

Trend and log

-
-

Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}

+
+
+ Name + {selectedBird.name} +
+
+ Band ID + {selectedBird.tagId} +
+
+ DOB + {formatDate(selectedBird.dateOfBirth)} +
+
+ Gotcha day + {formatDate(selectedBird.gotchaDay)} +
+
+ Species + {selectedBird.species} +
+
+ Latest weight + {formatWeight(selectedBird.latestWeightGrams)} +
+
+ +
+
+
+
+

Weight

+

Trend and log

-
- - - - - - - - - - - -
-

{trendCopy}

- {weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'} -
+

Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}

+
+
+ + + + + + + + + +
+

{trendCopy}

+ {weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}
+
-
- - -