diff --git a/backend/src/app.ts b/backend/src/app.ts index e22c686..102d4c2 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2014,7 +2014,7 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { - const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 365); + const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425); const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days); res.json({ weights: weights.map(normalizeWeight) }); } catch (error) { diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2fa2cf9..015914c 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -807,7 +807,7 @@ Requires auth. Lists weight entries for a bird in the active workspace. Query params: -- `days` optional, clamped to `1` through `365`, default `30` +- `days` optional, clamped to `1` through `425`, default `30` Response `200`: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25e0102..7c04843 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -224,6 +224,9 @@ type WeightDropAlert = { dropPercent: number; }; +type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; +type DismissedAlertMap = Record; + type PhotoCropState = { sourceDataUrl: string; fileName: string; @@ -247,6 +250,7 @@ type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; +const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts'; const emptyBirdForm: BirdFormState = { name: '', tagId: '', @@ -428,6 +432,13 @@ const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed( const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const daysBetweenDates = (startDate: string, endDate: string) => Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000); +const addYearsToDate = (date: Date, years: number) => { + const nextDate = new Date(date); + nextDate.setFullYear(nextDate.getFullYear() + years); + return nextDate; +}; +const OVERVIEW_WINDOW_DAYS = 30; +const OVERVIEW_HISTORY_DAYS = 425; const OVERVIEW_WIDTH = 520; const OVERVIEW_HEIGHT = 220; const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 }; @@ -501,6 +512,32 @@ const clearSessionToken = () => { const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? ''; +const readDismissedAlerts = (): DismissedAlertMap => { + const storedDismissals = window.localStorage.getItem(dismissedAlertsStorageKey); + + if (!storedDismissals) { + return {}; + } + + try { + const parsed = JSON.parse(storedDismissals); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as DismissedAlertMap) : {}; + } catch { + return {}; + } +}; + +const persistDismissedAlerts = (dismissedAlerts: DismissedAlertMap) => { + window.localStorage.setItem(dismissedAlertsStorageKey, JSON.stringify(dismissedAlerts)); +}; + +const buildDismissedAlertKey = ( + workspaceId: number | undefined, + birdId: string, + alertType: DismissibleAlertType, + signature: string, +) => `${workspaceId ?? 'workspace'}:${birdId}:${alertType}:${signature}`; + const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin); url.searchParams.set('redirectTo', window.location.href); @@ -800,7 +837,14 @@ const chartDots = (points: WeightRecord[], width = 520, height = 180) => { })); }; -const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => { +const buildOverviewSeries = ( + points: WeightRecord[], + minWeight: number, + maxWeight: number, + startDate: Date, + endDate: Date, + dateOffsetYears = 0, +) => { 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); @@ -809,7 +853,7 @@ const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeigh const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); return points.map((point) => { - const pointTime = parseDateValue(point.recordedOn).getTime(); + const pointTime = addYearsToDate(parseDateValue(point.recordedOn), dateOffsetYears).getTime(); const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth; const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight; @@ -825,6 +869,35 @@ 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 buildMemberSeries = ( + points: WeightRecord[], + minWeight: number, + maxWeight: number, + startDate: Date, + endDate: Date, + dateOffsetYears = 0, +) => { + 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 startMs = startDate.getTime(); + const endMs = endDate.getTime(); + const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); + const weightSpread = Math.max(maxWeight - minWeight, 1); + + return points.map((entry) => { + const pointTime = addYearsToDate(parseDateValue(entry.recordedOn), dateOffsetYears).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 assessBirdWeight = (bird: Bird): BirdWeightAssessment => { const reference = findParrotWeightReference(bird.species); @@ -896,6 +969,8 @@ function App() { const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); + const [allBirdVetVisits, setAllBirdVetVisits] = useState>({}); + const [dismissedAlerts, setDismissedAlerts] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [workspaceForm, setWorkspaceForm] = useState(emptyWorkspaceForm); @@ -961,9 +1036,23 @@ function App() { [birds, editingBirdId], ); + useEffect(() => { + setDismissedAlerts(readDismissedAlerts()); + }, [workspace?.id]); + + const overviewWindowStartDate = useMemo(() => { + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); + return startDate; + }, []); + const birdsWithRecentWeights = useMemo( - () => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0), - [allBirdWeights, birds], + () => + birds.filter((bird) => + (allBirdWeights[bird.id] ?? []).some((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate), + ), + [allBirdWeights, birds, overviewWindowStartDate], ); const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird); @@ -984,25 +1073,53 @@ function App() { [birds], ); + const getWeightRangeAlertSignature = (bird: Bird, assessment: OutOfRangeBirdWeightAssessment) => + `${assessment.status}:${bird.latestRecordedOn ?? 'none'}:${bird.latestWeightGrams ?? 'none'}:${assessment.reference.minGrams}-${assessment.reference.maxGrams}`; + + const getWeightDropAlertSignature = (alert: WeightDropAlert) => + `${alert.previousWeight.id}:${alert.previousWeight.recordedOn}:${alert.previousWeight.weightGrams}:${alert.latestWeight.id}:${alert.latestWeight.recordedOn}:${alert.latestWeight.weightGrams}`; + + const getVetVisitAlertSignature = (birdId: string) => { + const latestVisit = allBirdVetVisits[birdId]?.[0] ?? null; + return latestVisit ? `${latestVisit.id}:${latestVisit.visitedOn}` : 'no-vet-visit'; + }; + + const isAlertDismissed = (birdId: string, alertType: DismissibleAlertType, signature: string) => + Boolean(dismissedAlerts[buildDismissedAlertKey(workspace?.id, birdId, alertType, signature)]); + + const dismissAlert = (birdId: string, alertType: DismissibleAlertType, signature: string) => { + const alertKey = buildDismissedAlertKey(workspace?.id, birdId, alertType, signature); + setDismissedAlerts((current) => { + const next = { + ...current, + [alertKey]: true, + }; + persistDismissedAlerts(next); + return next; + }); + }; + const selectedBirdTrendCopy = useMemo(() => { - if (weights.length < 2) { + const visibleWeights = weights.filter((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate); + + if (visibleWeights.length < 2) { return 'Needs a few more entries before trend detection.'; } - const first = weights[0].weightGrams; - const last = weights[weights.length - 1].weightGrams; + const first = visibleWeights[0].weightGrams; + const last = visibleWeights[visibleWeights.length - 1].weightGrams; const delta = last - first; if (Math.abs(delta) < 1) { - return 'Weight has been steady over the last visible entries.'; + return 'Weight has been steady over the current 30-day window.'; } return delta > 0 - ? `Weight is up ${delta.toFixed(1)} g over the current window.` - : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`; - }, [weights]); + ? `Weight is up ${delta.toFixed(1)} g over the current 30-day window.` + : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current 30-day window.`; + }, [overviewWindowStartDate, weights]); - const outOfRangeBirds = useMemo( + const activeOutOfRangeBirds = useMemo( () => birds .map((bird) => { @@ -1021,7 +1138,15 @@ function App() { [birdWeightAssessments, birds], ); - const weightDropAlerts = useMemo( + const outOfRangeBirds = useMemo( + () => + activeOutOfRangeBirds.filter( + ({ bird, assessment }) => !isAlertDismissed(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment)), + ), + [activeOutOfRangeBirds, dismissedAlerts, workspace?.id], + ); + + const activeWeightDropAlerts = useMemo( () => birds .map((bird) => { @@ -1057,8 +1182,43 @@ function App() { [allBirdWeights, birds], ); + const weightDropAlerts = useMemo( + () => activeWeightDropAlerts.filter((alert) => !isAlertDismissed(alert.bird.id, 'weight-drop', getWeightDropAlertSignature(alert))), + [activeWeightDropAlerts, dismissedAlerts, workspace?.id], + ); + const totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length; + const vetVisitOverviewLoaded = + birds.length > 0 && birds.every((bird) => Object.prototype.hasOwnProperty.call(allBirdVetVisits, bird.id)); + + const activeVetVisitDueBirds = useMemo(() => { + if (!vetVisitOverviewLoaded) { + return []; + } + + const cutoffDate = new Date(); + cutoffDate.setHours(0, 0, 0, 0); + cutoffDate.setDate(cutoffDate.getDate() - 364); + + return birds.filter((bird) => { + const visits = allBirdVetVisits[bird.id] ?? []; + return !visits.some((visit) => parseDateValue(visit.visitedOn) >= cutoffDate); + }); + }, [allBirdVetVisits, birds, vetVisitOverviewLoaded]); + + const vetVisitDueBirds = useMemo( + () => + activeVetVisitDueBirds.filter( + (bird) => !isAlertDismissed(bird.id, 'vet-visit', getVetVisitAlertSignature(bird.id)), + ), + [activeVetVisitDueBirds, allBirdVetVisits, dismissedAlerts, workspace?.id], + ); + + const vetVisitDueNames = vetVisitDueBirds.slice(0, 3).map((bird) => bird.name).join(', '); + const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0); + const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]); + const filteredSpeciesOptions = useMemo(() => { const query = birdForm.species.trim().toLowerCase(); @@ -1072,66 +1232,86 @@ function App() { }, [birdForm.species]); const selectedBirdChart = useMemo(() => { - if (!weights.length) { + const endDate = new Date(); + endDate.setHours(0, 0, 0, 0); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); + const historicalStartDate = addYearsToDate(startDate, -1); + const historicalEndDate = addYearsToDate(endDate, -1); + const visibleWeights = weights.filter((entry) => { + const recordedOn = parseDateValue(entry.recordedOn); + return recordedOn >= startDate && recordedOn <= endDate; + }); + const historicalWeights = weights.filter((entry) => { + const recordedOn = parseDateValue(entry.recordedOn); + return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate; + }); + + if (!visibleWeights.length) { return { points: [] as { id: string; x: number; y: number; label: string }[], + historicalPoints: [] as { id: string; x: number; y: number; label: string }[], path: '', + historicalPath: '', isFlat: false, + historicalIsFlat: false, yTicks: [] as { label: string; y: number }[], - xTicks: [] as { label: string; x: number }[], + xTicks: [ + { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: MEMBER_CHART_PADDING.left }, + { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, + ] as { label: string; x: number }[], + visibleCount: 0, + historicalCount: historicalWeights.length, }; } - 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 allPlottedWeights = [...visibleWeights, ...historicalWeights]; + const rawMinWeight = Math.min(...allPlottedWeights.map((entry) => entry.weightGrams)); + const rawMaxWeight = Math.max(...allPlottedWeights.map((entry) => entry.weightGrams)); + const isFlat = Math.abs(Math.max(...visibleWeights.map((entry) => entry.weightGrams)) - Math.min(...visibleWeights.map((entry) => entry.weightGrams))) < 0.01; + const historicalIsFlat = + historicalWeights.length > 1 && + Math.abs( + Math.max(...historicalWeights.map((entry) => entry.weightGrams)) - Math.min(...historicalWeights.map((entry) => entry.weightGrams)), + ) < 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 points = buildMemberSeries(visibleWeights, minWeight, maxWeight, startDate, endDate); + const historicalPoints = buildMemberSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1); const path = toOverviewPath(points); + const historicalPath = toOverviewPath(historicalPoints); const midWeight = minWeight + (maxWeight - minWeight) / 2; const midDate = new Date((startMs + endMs) / 2); return { points, + historicalPoints, path, + historicalPath, isFlat, + historicalIsFlat, 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(startDate.toISOString().slice(0, 10)), 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 }, + { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, ], + visibleCount: visibleWeights.length, + historicalCount: historicalWeights.length, }; }, [weights]); const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0; + const hasSelectedBirdHistoricalLine = selectedBirdChart.historicalPoints.length >= 2 && selectedBirdChart.historicalPath.length > 0; const flockWeeklyTrendItems = useMemo(() => { return birds @@ -1169,19 +1349,32 @@ function App() { }, [allBirdWeights, birds]); 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); + startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); + const historicalStartDate = addYearsToDate(startDate, -1); + const historicalEndDate = addYearsToDate(endDate, -1); + + const plottedBirds = birds + .map((bird) => ({ + bird, + weights: (allBirdWeights[bird.id] ?? []).filter((entry) => { + const recordedOn = parseDateValue(entry.recordedOn); + return recordedOn >= overviewWindowStartDate && recordedOn <= endDate; + }), + historicalWeights: (allBirdWeights[bird.id] ?? []).filter((entry) => { + const recordedOn = parseDateValue(entry.recordedOn); + return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate; + }), + })) + .filter((entry) => entry.weights.length > 0); if (!plottedBirds.length) { return { plottedBirds, series: [], + historicalSeries: [], 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 }, @@ -1190,7 +1383,9 @@ function App() { }; } - const allWeights = plottedBirds.flatMap((entry) => entry.weights.map((weight) => weight.weightGrams)); + const allWeights = plottedBirds.flatMap((entry) => + [...entry.weights, ...entry.historicalWeights].map((weight) => weight.weightGrams), + ); const rawMinWeight = Math.min(...allWeights); const rawMaxWeight = Math.max(...allWeights); const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); @@ -1204,6 +1399,12 @@ function App() { bird, points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate), })), + historicalSeries: plottedBirds + .map(({ bird, historicalWeights }) => ({ + bird, + points: buildOverviewSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1), + })) + .filter((entry) => entry.points.length > 0), 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 }, @@ -1215,7 +1416,9 @@ function App() { { label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom }, ], }; - }, [allBirdWeights, birds]); + }, [allBirdWeights, birds, overviewWindowStartDate]); + + const overviewHistoricalSeriesCount = overviewChart.historicalSeries.length; const applySession = (session: AuthSessionPayload, token: string) => { setAuthToken(token); @@ -1255,6 +1458,7 @@ function App() { setWeights([]); setVetVisits([]); setAllBirdWeights({}); + setAllBirdVetVisits({}); setSelectedBirdId(''); setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); @@ -1426,7 +1630,7 @@ function App() { const loadBirdDetail = async () => { try { const [weightsResponse, visitsResponse] = await Promise.all([ - apiFetch(`/birds/${selectedBird.id}/weights?days=90`, authToken), + apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken), apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), ]); @@ -1438,7 +1642,12 @@ function App() { const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; setWeights(weightsData.weights ?? []); - setVetVisits(visitsData.vetVisits ?? []); + const nextVetVisits = visitsData.vetVisits ?? []; + setVetVisits(nextVetVisits); + setAllBirdVetVisits((current) => ({ + ...current, + [selectedBird.id]: nextVetVisits, + })); setEditingVetVisitId(''); setDeletingVetVisitId(''); } catch (loadError) { @@ -1459,7 +1668,7 @@ function App() { try { const responses = await Promise.all( birds.map(async (bird) => { - const response = await apiFetch(`/birds/${bird.id}/weights?days=30`, authToken); + const response = await apiFetch(`/birds/${bird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load overview weights.')); @@ -1479,6 +1688,36 @@ function App() { void loadAllBirdWeights(); }, [authToken, birds]); + useEffect(() => { + if (!authToken || !birds.length) { + setAllBirdVetVisits({}); + return; + } + + const loadAllBirdVetVisits = async () => { + try { + const responses = await Promise.all( + birds.map(async (bird) => { + const response = await apiFetch(`/birds/${bird.id}/vet-visits`, authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load overview vet visits.')); + } + + const data = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(response)) ?? {}; + return [bird.id, (data.vetVisits ?? []) as VetVisit[]] as const; + }), + ); + + setAllBirdVetVisits(Object.fromEntries(responses)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Unable to load overview vet visits.'); + } + }; + + void loadAllBirdVetVisits(); + }, [authToken, birds]); + useEffect(() => { if (!editingBirdId) { return; @@ -1634,6 +1873,7 @@ function App() { setEditingBirdId(''); setWeights([]); setVetVisits([]); + setAllBirdVetVisits({}); setActivePage('overview'); } catch (switchError) { setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); @@ -2161,11 +2401,16 @@ function App() { if (!data?.vetVisit) { throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`); } - setVetVisits((current) => - (isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort( + const nextVetVisits = ( + isEditingVetVisit ? vetVisits.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...vetVisits] + ).sort( (left, right) => right.visitedOn.localeCompare(left.visitedOn), - ), ); + setVetVisits(nextVetVisits); + setAllBirdVetVisits((current) => ({ + ...current, + [selectedBird.id]: nextVetVisits, + })); setVetVisitForm({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', @@ -2216,7 +2461,12 @@ function App() { throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.')); } - setVetVisits((current) => current.filter((visit) => visit.id !== visitId)); + const nextVetVisits = vetVisits.filter((visit) => visit.id !== visitId); + setVetVisits(nextVetVisits); + setAllBirdVetVisits((current) => ({ + ...current, + [selectedBird.id]: nextVetVisits, + })); if (editingVetVisitId === visitId) { handleCancelVetVisitEdit(); } @@ -2259,6 +2509,11 @@ function App() { delete next[selectedBird.id]; return next; }); + setAllBirdVetVisits((current) => { + const next = { ...current }; + delete next[selectedBird.id]; + return next; + }); setSelectedBirdId(''); setWeights([]); setVetVisits([]); @@ -2329,6 +2584,11 @@ function App() { delete next[flockTransferForm.birdId]; return next; }); + setAllBirdVetVisits((current) => { + const next = { ...current }; + delete next[flockTransferForm.birdId]; + return next; + }); setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); if (selectedBird?.id === flockTransferForm.birdId) { @@ -2638,6 +2898,18 @@ function App() { setShowWeightAlertModal(true); }; + const handleVetVisitReminderClick = () => { + const firstDueBird = vetVisitDueBirds[0]; + + if (!firstDueBird) { + return; + } + + setSelectedBirdId(firstDueBird.id); + setBulkWeightOpen(false); + setActivePage('flock'); + }; + if (authLoading) { return (
@@ -2867,7 +3139,10 @@ function App() { {totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'} ) : null} -

{birdsWithRecentWeights.length} birds with recent entries

+

+ {birdsWithRecentWeights.length} current + {overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''} +

@@ -2899,6 +3174,25 @@ function App() { {tick.label} ))} + {overviewChart.historicalSeries.map(({ bird, points }) => ( + + {points.length > 1 ? ( + + ) : null} + {points.map((point) => ( + + {`${bird.name} previous year: ${point.label}`} + + ))} + + ))} {overviewChart.series.map(({ bird, points }) => ( {points.length > 1 ? ( @@ -2916,11 +3210,14 @@ function App() {
{overviewChart.plottedBirds.map(({ bird }) => { + const hasHistoricalData = overviewChart.historicalSeries.some((entry) => entry.bird.id === bird.id); + return (
{bird.name} + {hasHistoricalData ? Solid current, dashed previous year : null}
); @@ -2963,6 +3260,21 @@ function App() { ) : null} + {vetVisitDueBirds.length ? ( +
+ Vet visit reminder + + {vetVisitDueBirds.length} member{vetVisitDueBirds.length === 1 ? '' : 's'} need annual visit review + + + No vet visit logged in the last 365 days for {vetVisitDueNames} + {vetVisitDueOverflowCount ? ` and ${vetVisitDueOverflowCount} more` : ''}. + + +
+ ) : null}
Weekly flock changes {flockWeeklyTrendItems.length ? ( @@ -3108,40 +3420,77 @@ function App() {
- {birds.map((bird) => ( -
- {formatWeight(bird.latestWeightGrams)} - {birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? ( - - {birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'} - - ) : null} - - ))} + {formatWeight(bird.latestWeightGrams)} + + {weightRangeAlert || hasVetVisitAlert ? ( +
+ {weightRangeAlert ? ( + + {weightRangeAlert.assessment.status === 'below' ? 'Below chart range' : 'Above chart range'} + + + ) : null} + {hasVetVisitAlert ? ( + + Annual vet visit due + + + ) : null} +
+ ) : null} + + ); + })} @@ -3342,6 +3691,42 @@ function App() { {tick.label} ))} + {hasSelectedBirdHistoricalLine && selectedBirdChart.historicalIsFlat ? ( + + ) : null} + {hasSelectedBirdHistoricalLine && !selectedBirdChart.historicalIsFlat ? ( + + ) : null} + {selectedBirdChart.historicalPoints.map((point) => ( + + {`${selectedBird.name} previous year: ${point.label}`} + + ))} {hasSelectedBirdLine && selectedBirdChart.isFlat ? (

{selectedBirdTrendCopy}

- {weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'} + + {selectedBirdChart.visibleCount + ? `${selectedBirdChart.visibleCount} current${ + selectedBirdChart.historicalCount > 0 ? `, ${selectedBirdChart.historicalCount} previous-year` : '' + }` + : 'No current data yet'} +
@@ -4386,6 +4777,13 @@ function App() { {bird.species} • Latest weight {formatWeight(bird.latestWeightGrams)} • Typical range{' '} {formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)} + ))} {weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => ( @@ -4395,6 +4793,24 @@ function App() { {formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '} {formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)} + ))} diff --git a/frontend/src/index.css b/frontend/src/index.css index af270c2..9ef736b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -737,7 +737,6 @@ textarea { .bird-card { width: 100%; - text-align: left; padding: 1rem; border: 1px solid rgba(95, 121, 77, 0.16); display: grid; @@ -746,9 +745,33 @@ textarea { background: var(--card-bg); } +.bird-card-select { + display: grid; + gap: 0.35rem; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + box-shadow: none; +} + +.bird-card-select:hover { + transform: none; + box-shadow: none; +} + +.bird-alert-stack { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + .bird-alert-badge { display: inline-flex; align-items: center; + gap: 0.45rem; justify-self: start; padding: 0.28rem 0.7rem; border-radius: 999px; @@ -761,6 +784,28 @@ textarea { text-transform: uppercase; } +.alert-dismiss-button { + width: auto; + margin: 0; + border: 0; + border-left: 1px solid rgba(203, 58, 53, 0.22); + border-radius: 0; + padding: 0 0 0 0.45rem; + background: transparent; + color: inherit; + box-shadow: none; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0; + text-transform: none; +} + +.alert-dismiss-button:hover { + transform: none; + box-shadow: none; + text-decoration: underline; +} + .bird-card:hover, .bird-card.active { transform: translateY(-2px); @@ -807,6 +852,15 @@ textarea { font-size: 11px; } +.historical-weight-line, +.historical-weight-dot { + opacity: 0.48; +} + +.historical-weight-line { + stroke-dasharray: 7 6; +} + .chart-footer, .recent-list, .detail-grid, @@ -959,6 +1013,16 @@ textarea { align-items: center; } +.legend-card div { + display: grid; + gap: 0.15rem; +} + +.legend-card div span { + color: var(--muted); + font-size: 0.85rem; +} + .legend-swatch { width: 14px; height: 14px;