Added vet alert and hisorical weight trends

This commit is contained in:
blaisadmin
2026-04-18 18:08:45 -04:00
parent e06dae91a3
commit 8dd3cd50fc
4 changed files with 570 additions and 90 deletions
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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`:
+503 -87
View File
@@ -224,6 +224,9 @@ type WeightDropAlert = {
dropPercent: number;
};
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
type DismissedAlertMap = Record<string, boolean>;
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<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [allBirdVetVisits, setAllBirdVetVisits] = useState<Record<string, VetVisit[]>>({});
const [dismissedAlerts, setDismissedAlerts] = useState<DismissedAlertMap>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(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 (
<main className="auth-shell">
@@ -2867,7 +3139,10 @@ function App() {
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
</button>
) : null}
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
<p className="muted">
{birdsWithRecentWeights.length} current
{overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''}
</p>
</div>
</div>
@@ -2899,6 +3174,25 @@ function App() {
{tick.label}
</text>
))}
{overviewChart.historicalSeries.map(({ bird, points }) => (
<g key={`${bird.id}-historical`}>
{points.length > 1 ? (
<path
d={toOverviewPath(points)}
fill="none"
stroke={bird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{points.map((point) => (
<circle key={`${point.id}-historical`} cx={point.x} cy={point.y} r="3.5" fill={bird.chartColor} className="historical-weight-dot">
<title>{`${bird.name} previous year: ${point.label}`}</title>
</circle>
))}
</g>
))}
{overviewChart.series.map(({ bird, points }) => (
<g key={bird.id}>
{points.length > 1 ? (
@@ -2916,11 +3210,14 @@ function App() {
<div className="legend-grid">
{overviewChart.plottedBirds.map(({ bird }) => {
const hasHistoricalData = overviewChart.historicalSeries.some((entry) => entry.bird.id === bird.id);
return (
<article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: bird.chartColor }} />
<div>
<strong>{bird.name}</strong>
{hasHistoricalData ? <span>Solid current, dashed previous year</span> : null}
</div>
</article>
);
@@ -2963,6 +3260,21 @@ function App() {
</button>
</article>
) : null}
{vetVisitDueBirds.length ? (
<article className="summary-card summary-alert-card">
<span>Vet visit reminder</span>
<strong>
{vetVisitDueBirds.length} member{vetVisitDueBirds.length === 1 ? '' : 's'} need annual visit review
</strong>
<span>
No vet visit logged in the last 365 days for {vetVisitDueNames}
{vetVisitDueOverflowCount ? ` and ${vetVisitDueOverflowCount} more` : ''}.
</span>
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
Review vet visits
</button>
</article>
) : null}
<article className="summary-card">
<span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? (
@@ -3108,40 +3420,77 @@ function App() {
</div>
</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"
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()}
{birds.map((bird) => {
const weightRangeAlert = outOfRangeBirds.find((alert) => alert.bird.id === bird.id) ?? null;
const vetVisitAlertSignature = getVetVisitAlertSignature(bird.id);
const hasVetVisitAlert = vetVisitDueBirdIds.has(bird.id);
return (
<article
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
>
<button className="bird-card-select" 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>
)}
<div className="bird-card-copy">
<span className="bird-card-title">
<span>{bird.name}</span>
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
{getBirdGenderSymbol(bird)}
</span>
</span>
<small>{bird.species}</small>
</div>
</div>
)}
<div className="bird-card-copy">
<span className="bird-card-title">
<span>{bird.name}</span>
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
{getBirdGenderSymbol(bird)}
</span>
</span>
<small>{bird.species}</small>
</div>
</div>
<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>
))}
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
{weightRangeAlert || hasVetVisitAlert ? (
<div className="bird-alert-stack">
{weightRangeAlert ? (
<span className="bird-alert-badge">
{weightRangeAlert.assessment.status === 'below' ? 'Below chart range' : 'Above chart range'}
<button
className="alert-dismiss-button"
onClick={() =>
dismissAlert(
bird.id,
'weight-range',
getWeightRangeAlertSignature(bird, weightRangeAlert.assessment),
)
}
type="button"
aria-label={`Dismiss weight range alert for ${bird.name}`}
>
Dismiss
</button>
</span>
) : null}
{hasVetVisitAlert ? (
<span className="bird-alert-badge">
Annual vet visit due
<button
className="alert-dismiss-button"
onClick={() => dismissAlert(bird.id, 'vet-visit', vetVisitAlertSignature)}
type="button"
aria-label={`Dismiss annual vet visit alert for ${bird.name}`}
>
Dismiss
</button>
</span>
) : null}
</div>
) : null}
</article>
);
})}
</div>
</aside>
@@ -3342,6 +3691,42 @@ function App() {
{tick.label}
</text>
))}
{hasSelectedBirdHistoricalLine && selectedBirdChart.historicalIsFlat ? (
<line
x1={selectedBirdChart.historicalPoints[0].x}
y1={selectedBirdChart.historicalPoints[0].y}
x2={selectedBirdChart.historicalPoints[selectedBirdChart.historicalPoints.length - 1].x}
y2={selectedBirdChart.historicalPoints[selectedBirdChart.historicalPoints.length - 1].y}
stroke={selectedBird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{hasSelectedBirdHistoricalLine && !selectedBirdChart.historicalIsFlat ? (
<path
d={selectedBirdChart.historicalPath}
fill="none"
stroke={selectedBird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{selectedBirdChart.historicalPoints.map((point) => (
<circle
key={`${point.id}-historical`}
cx={point.x}
cy={point.y}
r="4"
fill={selectedBird.chartColor}
stroke="#fffdf9"
strokeWidth="2"
className="historical-weight-dot"
>
<title>{`${selectedBird.name} previous year: ${point.label}`}</title>
</circle>
))}
{hasSelectedBirdLine && selectedBirdChart.isFlat ? (
<line
x1={selectedBirdChart.points[0].x}
@@ -3364,7 +3749,13 @@ function App() {
</svg>
<div className="chart-footer">
<p>{selectedBirdTrendCopy}</p>
<span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
<span>
{selectedBirdChart.visibleCount
? `${selectedBirdChart.visibleCount} current${
selectedBirdChart.historicalCount > 0 ? `, ${selectedBirdChart.historicalCount} previous-year` : ''
}`
: 'No current data yet'}
</span>
</div>
</div>
@@ -4386,6 +4777,13 @@ function App() {
{bird.species} Latest weight {formatWeight(bird.latestWeightGrams)} Typical range{' '}
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
</span>
<button
className="range-alert-button"
onClick={() => dismissAlert(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment))}
type="button"
>
Dismiss for {bird.name}
</button>
</article>
))}
{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)}
</span>
<button
className="range-alert-button"
onClick={() =>
dismissAlert(
bird.id,
'weight-drop',
getWeightDropAlertSignature({
bird,
previousWeight,
latestWeight,
dropPercent,
})
)
}
type="button"
>
Dismiss for {bird.name}
</button>
</article>
))}
</div>
+65 -1
View File
@@ -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;