Major updates, fixed UI, added features like weight notifications
This commit is contained in:
+593
-55
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user