Added vet alert and hisorical weight trends
This commit is contained in:
+503
-87
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user