diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6b19c55..f19846a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -210,6 +210,13 @@ type OutOfRangeBirdWeightAssessment = {
varianceGrams: number;
};
+type WeightDropAlert = {
+ bird: Bird;
+ previousWeight: WeightRecord;
+ latestWeight: WeightRecord;
+ dropPercent: number;
+};
+
type PhotoCropState = {
sourceDataUrl: string;
fileName: string;
@@ -405,6 +412,8 @@ const formatDateTime = (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 daysBetweenDates = (startDate: string, endDate: string) =>
+ Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
@@ -995,6 +1004,44 @@ function App() {
[birdWeightAssessments, birds],
);
+ const weightDropAlerts = useMemo(
+ () =>
+ birds
+ .map((bird) => {
+ const birdWeights = [...(allBirdWeights[bird.id] ?? [])].sort(
+ (firstEntry, secondEntry) => parseDateValue(firstEntry.recordedOn).getTime() - parseDateValue(secondEntry.recordedOn).getTime(),
+ );
+
+ if (birdWeights.length < 2) {
+ return null;
+ }
+
+ const latestWeight = birdWeights[birdWeights.length - 1];
+ const previousWeight = birdWeights[birdWeights.length - 2];
+
+ if (previousWeight.weightGrams <= 0 || daysBetweenDates(previousWeight.recordedOn, latestWeight.recordedOn) > 2) {
+ return null;
+ }
+
+ const dropPercent = ((previousWeight.weightGrams - latestWeight.weightGrams) / previousWeight.weightGrams) * 100;
+
+ if (dropPercent < 5 || dropPercent > 10) {
+ return null;
+ }
+
+ return {
+ bird,
+ previousWeight,
+ latestWeight,
+ dropPercent,
+ };
+ })
+ .filter((alert): alert is WeightDropAlert => alert !== null),
+ [allBirdWeights, birds],
+ );
+
+ const totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length;
+
const filteredSpeciesOptions = useMemo(() => {
const query = birdForm.species.trim().toLowerCase();
@@ -2531,7 +2578,7 @@ function App() {
};
const handleWeightRangeAlertClick = () => {
- if (!outOfRangeBirds.length) {
+ if (!totalWeightAlerts) {
return;
}
setShowWeightAlertModal(true);
@@ -2719,9 +2766,9 @@ function App() {
30-day flock weight snapshot
- {outOfRangeBirds.length ? (
+ {totalWeightAlerts ? (
- {outOfRangeBirds.length} weight range alert{outOfRangeBirds.length === 1 ? '' : 's'}
+ {totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
) : null}
{birdsWithRecentWeights.length} birds with recent entries
@@ -2799,12 +2846,22 @@ function App() {
Members still needing a first weight
) : null}
- {outOfRangeBirds.length ? (
+ {totalWeightAlerts ? (
- Weight range alerts
+ Weight alerts
- {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
+ {totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review
+ {outOfRangeBirds.length ? (
+
+ {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
+
+ ) : null}
+ {weightDropAlerts.length ? (
+
+ {weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries
+
+ ) : null}
Review alerts
@@ -4213,19 +4270,19 @@ function App() {
Weight alert
-
Birds outside typical chart ranges
+
Birds needing weight review
setShowWeightAlertModal(false)} type="button">
Close
- These alerts use the BirdSupplies species chart as a general reference. If a reading is unexpected or concerning, please consult your
- veterinarian.
+ Range alerts use the BirdSupplies species chart as a general reference. Drop alerts compare the two most recent recorded days and
+ flag a 5-10% decrease. If a reading is unexpected or concerning, please consult your veterinarian.
{outOfRangeBirds.map(({ bird, assessment }) => (
-
+
{bird.name} is {assessment.status === 'below' ? 'below' : 'above'} the typical range
@@ -4235,6 +4292,15 @@ function App() {
))}
+ {weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => (
+
+ {bird.name} is down {dropPercent.toFixed(1)}% between recent entries
+
+ {formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '}
+ {formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)}
+
+
+ ))}