Major updates, fixed UI, added features like weight notifications

This commit is contained in:
blaisadmin
2026-04-09 23:02:01 -04:00
parent 0f3da53111
commit 27bc463258
5 changed files with 1016 additions and 114 deletions
+593 -55
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
@@ -130,6 +131,35 @@ type AuthNotice = {
previewUrl?: string | null;
};
type BulkWeightRowState = {
weightGrams: string;
};
type BirdWeightAssessment =
| {
status: 'no_match';
reference: null;
}
| {
status: 'no_weight';
reference: ParrotWeightReference;
}
| {
status: 'reference_only';
reference: Extract<ParrotWeightReference, { kind: 'approximate' }>;
}
| {
status: 'within' | 'below' | 'above';
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
varianceGrams: number;
};
type OutOfRangeBirdWeightAssessment = {
status: 'below' | 'above';
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
varianceGrams: number;
};
type PhotoCropState = {
sourceDataUrl: string;
fileName: string;
@@ -279,6 +309,7 @@ const formatShortDate = (value: string | null) => {
};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
@@ -287,6 +318,9 @@ const PHOTO_MAX_BYTES = 900_000;
const PHOTO_EXPORT_SIZES = [720, 600, 480];
const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66];
const PHOTO_PREVIEW_SIZE = 112;
const MEMBER_CHART_WIDTH = 520;
const MEMBER_CHART_HEIGHT = 180;
const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 };
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? '';
@@ -336,6 +370,7 @@ const createApiHeaders = (token?: string, headers?: HeadersInit) => {
const apiFetch = (path: string, token?: string, init?: RequestInit) =>
fetch(`${apiBaseUrl}${path}`, {
...init,
cache: 'no-store',
headers: createApiHeaders(token, init?.headers),
});
@@ -587,6 +622,53 @@ const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeigh
const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
const assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
const reference = findParrotWeightReference(bird.species);
if (!reference) {
return {
status: 'no_match',
reference: null,
};
}
if (bird.latestWeightGrams === null) {
return {
status: 'no_weight',
reference,
};
}
if (reference.kind === 'approximate') {
return {
status: 'reference_only',
reference,
};
}
if (bird.latestWeightGrams < reference.minGrams) {
return {
status: 'below',
reference,
varianceGrams: reference.minGrams - bird.latestWeightGrams,
};
}
if (bird.latestWeightGrams > reference.maxGrams) {
return {
status: 'above',
reference,
varianceGrams: bird.latestWeightGrams - reference.maxGrams,
};
}
return {
status: 'within',
reference,
varianceGrams: 0,
};
};
function App() {
const [activePage, setActivePage] = useState<AppPage>('overview');
const [authToken, setAuthToken] = useState('');
@@ -620,6 +702,12 @@ function App() {
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
const [bulkWeightOpen, setBulkWeightOpen] = useState(false);
const [savingBulkWeights, setSavingBulkWeights] = useState(false);
const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10));
const [bulkWeightRows, setBulkWeightRows] = useState<Record<string, BulkWeightRowState>>({});
const [weightForm, setWeightForm] = useState({
weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10),
@@ -654,11 +742,24 @@ function App() {
[allBirdWeights, birds],
);
const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
);
const birdWeightAssessments = useMemo(
() =>
Object.fromEntries(
birds.map((bird) => [
bird.id,
assessBirdWeight(bird),
]),
) as Record<string, BirdWeightAssessment>,
[birds],
);
const selectedBirdTrendCopy = useMemo(() => {
if (weights.length < 2) {
return 'Needs a few more entries before trend detection.';
@@ -677,6 +778,99 @@ function App() {
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]);
const outOfRangeBirds = useMemo(
() =>
birds
.map((bird) => {
const assessment = birdWeightAssessments[bird.id];
if (!assessment || (assessment.status !== 'below' && assessment.status !== 'above')) {
return null;
}
return {
bird,
assessment: assessment as OutOfRangeBirdWeightAssessment,
};
})
.filter((item): item is { bird: Bird; assessment: OutOfRangeBirdWeightAssessment } => item !== null),
[birdWeightAssessments, birds],
);
const filteredSpeciesOptions = useMemo(() => {
const query = birdForm.species.trim().toLowerCase();
if (!query) {
return parrotSpeciesOptions.slice(0, 12);
}
return parrotSpeciesOptions
.filter((speciesOption) => speciesOption.toLowerCase().includes(query))
.slice(0, 12);
}, [birdForm.species]);
const selectedBirdChart = useMemo(() => {
if (!weights.length) {
return {
points: [] as { id: string; x: number; y: number; label: string }[],
path: '',
isFlat: false,
yTicks: [] as { label: string; y: number }[],
xTicks: [] as { label: string; x: number }[],
};
}
const rawMinWeight = Math.min(...weights.map((entry) => entry.weightGrams));
const rawMaxWeight = Math.max(...weights.map((entry) => entry.weightGrams));
const isFlat = Math.abs(rawMaxWeight - rawMinWeight) < 0.01;
const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - padding);
const maxWeight = rawMaxWeight + padding;
const innerWidth = MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.left - MEMBER_CHART_PADDING.right;
const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom;
const startDate = parseDateValue(weights[0].recordedOn);
const endDate = parseDateValue(weights[weights.length - 1].recordedOn);
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
const weightSpread = Math.max(maxWeight - minWeight, 1);
const points = weights.map((entry) => {
const pointTime = parseDateValue(entry.recordedOn).getTime();
const x = MEMBER_CHART_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = MEMBER_CHART_PADDING.top + (1 - (entry.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: entry.id,
x,
y,
label: `${entry.weightGrams.toFixed(1)} g on ${formatShortDate(entry.recordedOn)}`,
};
});
const path = toOverviewPath(points);
const midWeight = minWeight + (maxWeight - minWeight) / 2;
const midDate = new Date((startMs + endMs) / 2);
return {
points,
path,
isFlat,
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom },
],
xTicks: [
{ label: formatShortDate(weights[0].recordedOn), x: MEMBER_CHART_PADDING.left },
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 },
{ label: formatShortDate(weights[weights.length - 1].recordedOn), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right },
],
};
}, [weights]);
const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0;
const flockWeeklyTrendItems = useMemo(() => {
return birds
.map((bird) => {
@@ -993,6 +1187,13 @@ function App() {
setPhotoDrag(null);
}, [editingBird, editingBirdId]);
useEffect(() => {
setBulkWeightRows((current) => {
const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const);
return Object.fromEntries(nextEntries);
});
}, [birds]);
const startCreateBird = () => {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
@@ -1361,6 +1562,112 @@ function App() {
}
};
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
setBulkWeightRows((current) => ({
...current,
[birdId]: {
weightGrams,
},
}));
};
const handleBulkWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const entries = birds
.map((bird) => ({
bird,
weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '',
}))
.filter((entry) => entry.weightGrams);
if (!entries.length) {
setError('Add at least one weight before saving the bulk update.');
return;
}
setError('');
setSavingBulkWeights(true);
try {
const savedWeights: Record<string, WeightRecord> = {};
for (const entry of entries) {
const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weightGrams: Number(entry.weightGrams),
recordedOn: bulkWeightDate,
notes: '',
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`));
}
const data = await readJsonSafely<{ weight?: WeightRecord }>(response);
if (!data?.weight) {
throw new Error(`Unable to save weight for ${entry.bird.name}.`);
}
savedWeights[entry.bird.id] = data.weight;
}
setBirds((current) =>
current.map((bird) => {
const savedWeight = savedWeights[bird.id];
if (!savedWeight) {
return bird;
}
return {
...bird,
latestWeightGrams: savedWeight.weightGrams,
latestRecordedOn: savedWeight.recordedOn,
};
}),
);
setAllBirdWeights((current) => {
const next = { ...current };
const limitDate = new Date();
limitDate.setDate(limitDate.getDate() - 29);
const limitMs = new Date(limitDate.toDateString()).getTime();
for (const [birdId, savedWeight] of Object.entries(savedWeights)) {
const nextWeights = [...(current[birdId] ?? []), savedWeight]
.sort((left, right) => left.recordedOn.localeCompare(right.recordedOn))
.filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs);
next[birdId] = nextWeights;
}
return next;
});
if (selectedBird?.id && savedWeights[selectedBird.id]) {
setWeights((current) =>
[...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) =>
left.recordedOn.localeCompare(right.recordedOn),
),
);
}
setBulkWeightRows((current) =>
Object.fromEntries(
Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]),
),
);
} catch (bulkError) {
setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.');
} finally {
setSavingBulkWeights(false);
}
};
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -1599,6 +1906,13 @@ function App() {
}
};
const handleWeightRangeAlertClick = () => {
if (!outOfRangeBirds.length) {
return;
}
setShowWeightAlertModal(true);
};
if (authLoading) {
return (
<main className="auth-shell">
@@ -1721,7 +2035,11 @@ function App() {
return (
<main className="app-shell">
<aside className="side-nav panel">
<div className="side-rail">
<div className="brand-lockup">
<img className="side-nav-logo" src={flockPalLandingArt} alt="FlockPal" />
</div>
<aside className="side-nav panel">
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
@@ -1757,18 +2075,10 @@ function App() {
<button className="secondary-button" onClick={handleLogout} type="button">
Log out
</button>
</aside>
</aside>
</div>
<section className="content-shell">
{activePage !== 'settings' ? (
<section className="hero-card">
<div>
<p className="eyebrow">Dashboard</p>
<img className="dashboard-logo" src={flockPalLandingArt} alt="FlockPal" />
</div>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
{activePage === 'overview' ? (
@@ -1779,7 +2089,14 @@ function App() {
<p className="eyebrow">Overview</p>
<h2>30-day flock weight snapshot</h2>
</div>
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
<div className="button-row overview-alert-actions">
{outOfRangeBirds.length ? (
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
{outOfRangeBirds.length} weight range alert{outOfRangeBirds.length === 1 ? '' : 's'}
</button>
) : null}
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
</div>
</div>
<div className="chart-card overview-chart-card">
@@ -1853,6 +2170,17 @@ function App() {
<span>Members still needing a first weight</span>
</article>
) : null}
{outOfRangeBirds.length ? (
<article className="summary-card summary-alert-card">
<span>Weight range alerts</span>
<strong>
{outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
</strong>
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
Review alerts
</button>
</article>
) : null}
<article className="summary-card">
<span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? (
@@ -1884,47 +2212,121 @@ function App() {
) : null}
{activePage === 'flock' ? (
<section className={selectedBird ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
<p className="muted">Select a bird to view more details.</p>
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
<p className="muted">Select a bird to view more details.</p>
</div>
<div className="button-row">
<button className="secondary-button" onClick={() => setBulkWeightOpen((current) => !current)} type="button">
{bulkWeightOpen ? 'Hide bulk add' : 'Bulk add weights'}
</button>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div>
</div>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div>
<div className="bird-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
onClick={() => setSelectedBirdId(bird.id)}
type="button"
>
<div className="bird-card-header">
{bird.photoDataUrl ? (
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
) : (
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
{bird.name.slice(0, 1).toUpperCase()}
<div className="bird-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
onClick={() => setSelectedBirdId(bird.id)}
type="button"
style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
>
<div className="bird-card-header">
{bird.photoDataUrl ? (
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
) : (
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
{bird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div className="bird-card-copy">
<span>{bird.name}</span>
<small>{bird.species}</small>
</div>
)}
<div className="bird-card-copy">
<span>{bird.name}</span>
<small>{bird.species}</small>
</div>
</div>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
))}
</div>
</aside>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
{birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? (
<span className="bird-alert-badge">
{birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'}
</span>
) : null}
</button>
))}
</div>
</aside>
{selectedBird ? (
<section className="panel flock-member-panel">
{showFlockDetailColumn ? (
<section className="flock-detail-column">
{bulkWeightOpen ? (
<section className="panel bulk-weight-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Weigh-in</p>
<h2>Bulk add weights</h2>
</div>
<label className="bulk-date-field">
Date
<input type="date" value={bulkWeightDate} onChange={(event) => setBulkWeightDate(event.target.value)} required />
</label>
</div>
<form className="bulk-weight-form" onSubmit={handleBulkWeightSubmit}>
<div className="bulk-weight-table-shell">
<table className="bulk-weight-table">
<thead>
<tr>
<th>Flock member</th>
<th>Last weight</th>
<th>Weight today</th>
</tr>
</thead>
<tbody>
{birds.map((bird) => (
<tr key={bird.id}>
<td>{bird.name}</td>
<td>{formatWeight(bird.latestWeightGrams)}</td>
<td>
<input
type="number"
min="1"
step="0.1"
value={bulkWeightRows[bird.id]?.weightGrams ?? ''}
onChange={(event) => handleBulkWeightValueChange(bird.id, event.target.value)}
placeholder="g"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="button-row">
<button className="primary-button" type="submit" disabled={savingBulkWeights}>
{savingBulkWeights ? 'Saving weights...' : 'Save bulk weights'}
</button>
<button
className="secondary-button"
onClick={() =>
setBulkWeightRows((current) => Object.fromEntries(Object.keys(current).map((birdId) => [birdId, { weightGrams: '' }])))
}
type="button"
disabled={savingBulkWeights}
>
Clear entries
</button>
</div>
</form>
</section>
) : null}
{selectedBird ? (
<section className="panel flock-member-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock member</p>
@@ -1996,14 +2398,69 @@ function App() {
<p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p>
</div>
<div className="chart-card">
<svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart">
<svg
viewBox={`0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}`}
className="weight-chart"
role="img"
aria-label="Selected flock member weight trend chart"
>
<defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="100%" stopColor={selectedBird.chartColor} />
</linearGradient>
</defs>
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
{selectedBirdChart.yTicks.map((tick) => (
<g key={tick.label}>
<line
x1={MEMBER_CHART_PADDING.left}
y1={tick.y}
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
y2={tick.y}
className="chart-grid-line"
/>
<text x={MEMBER_CHART_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={MEMBER_CHART_PADDING.left}
y1={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
y2={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
className="chart-axis-line"
/>
{selectedBirdChart.xTicks.map((tick) => (
<text
key={`${tick.label}-${tick.x}`}
x={tick.x}
y={MEMBER_CHART_HEIGHT - 10}
textAnchor="middle"
className="chart-axis-label"
>
{tick.label}
</text>
))}
{hasSelectedBirdLine && selectedBirdChart.isFlat ? (
<line
x1={selectedBirdChart.points[0].x}
y1={selectedBirdChart.points[0].y}
x2={selectedBirdChart.points[selectedBirdChart.points.length - 1].x}
y2={selectedBirdChart.points[selectedBirdChart.points.length - 1].y}
stroke={selectedBird.chartColor}
strokeWidth="4"
strokeLinecap="round"
/>
) : null}
{hasSelectedBirdLine && !selectedBirdChart.isFlat ? (
<path d={selectedBirdChart.path} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
) : null}
{selectedBirdChart.points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="5" fill={selectedBird.chartColor} stroke="#fffdf9" strokeWidth="2">
<title>{point.label}</title>
</circle>
))}
</svg>
<div className="chart-footer">
<p>{selectedBirdTrendCopy}</p>
@@ -2116,8 +2573,10 @@ function App() {
</section>
</div>
</>
</section>
) : null}
</section>
) : null}
</section>
) : null}
</section>
) : null}
@@ -2471,9 +2930,49 @@ function App() {
Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
</label>
<label>
<label className="species-picker-field">
Species
<input value={birdForm.species} onChange={(event) => setBirdForm({ ...birdForm, species: event.target.value })} required />
<div className="species-picker">
<input
value={birdForm.species}
onChange={(event) => {
setBirdForm({ ...birdForm, species: event.target.value });
setSpeciesPickerOpen(true);
}}
onFocus={() => setSpeciesPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setSpeciesPickerOpen(false);
}, 120);
}}
placeholder="Start typing a species"
autoComplete="off"
required
/>
{speciesPickerOpen ? (
<div className="species-picker-menu">
{filteredSpeciesOptions.length ? (
filteredSpeciesOptions.map((speciesOption) => (
<button
key={speciesOption}
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault();
setBirdForm({ ...birdForm, species: speciesOption });
setSpeciesPickerOpen(false);
}}
type="button"
>
{speciesOption}
</button>
))
) : (
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
)}
</div>
) : null}
</div>
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
</label>
<label>
DOB
@@ -2668,6 +3167,45 @@ function App() {
</section>
) : null}
</section>
{showWeightAlertModal ? (
<div className="app-modal-backdrop" role="presentation" onClick={() => setShowWeightAlertModal(false)}>
<section
className="app-modal weight-alert-modal"
role="dialog"
aria-modal="true"
aria-labelledby="weight-alert-title"
onClick={(event) => event.stopPropagation()}
>
<div className="panel-header">
<div>
<p className="eyebrow">Weight alert</p>
<h2 id="weight-alert-title">Birds outside typical chart ranges</h2>
</div>
<button className="secondary-button" onClick={() => setShowWeightAlertModal(false)} type="button">
Close
</button>
</div>
<p className="muted">
These alerts use the BirdSupplies species chart as a general reference. If a reading is unexpected or concerning, please consult your
veterinarian.
</p>
<div className="modal-alert-list">
{outOfRangeBirds.map(({ bird, assessment }) => (
<article key={bird.id} className="summary-card summary-alert-card">
<strong>
{bird.name} is {assessment.status === 'below' ? 'below' : 'above'} the typical range
</strong>
<span>
{bird.species} Latest weight {formatWeight(bird.latestWeightGrams)} Typical range{' '}
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
</span>
</article>
))}
</div>
</section>
</div>
) : null}
</main>
);
}