Added vet alert and hisorical weight trends
This commit is contained in:
+1
-1
@@ -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) => {
|
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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);
|
const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days);
|
||||||
res.json({ weights: weights.map(normalizeWeight) });
|
res.json({ weights: weights.map(normalizeWeight) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ Requires auth. Lists weight entries for a bird in the active workspace.
|
|||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
|
|
||||||
- `days` optional, clamped to `1` through `365`, default `30`
|
- `days` optional, clamped to `1` through `425`, default `30`
|
||||||
|
|
||||||
Response `200`:
|
Response `200`:
|
||||||
|
|
||||||
|
|||||||
+477
-61
@@ -224,6 +224,9 @@ type WeightDropAlert = {
|
|||||||
dropPercent: number;
|
dropPercent: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
|
||||||
|
type DismissedAlertMap = Record<string, boolean>;
|
||||||
|
|
||||||
type PhotoCropState = {
|
type PhotoCropState = {
|
||||||
sourceDataUrl: string;
|
sourceDataUrl: string;
|
||||||
fileName: 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 apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||||
const sessionTokenStorageKey = 'flockpal_auth_token';
|
const sessionTokenStorageKey = 'flockpal_auth_token';
|
||||||
|
const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts';
|
||||||
const emptyBirdForm: BirdFormState = {
|
const emptyBirdForm: BirdFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
tagId: '',
|
tagId: '',
|
||||||
@@ -428,6 +432,13 @@ const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(
|
|||||||
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
||||||
const daysBetweenDates = (startDate: string, endDate: string) =>
|
const daysBetweenDates = (startDate: string, endDate: string) =>
|
||||||
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
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_WIDTH = 520;
|
||||||
const OVERVIEW_HEIGHT = 220;
|
const OVERVIEW_HEIGHT = 220;
|
||||||
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
|
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
|
||||||
@@ -501,6 +512,32 @@ const clearSessionToken = () => {
|
|||||||
|
|
||||||
const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? '';
|
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 oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
|
||||||
const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin);
|
const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin);
|
||||||
url.searchParams.set('redirectTo', window.location.href);
|
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 innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
|
||||||
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
|
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
|
||||||
const weightSpread = Math.max(maxWeight - minWeight, 1);
|
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);
|
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
return points.map((point) => {
|
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 x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
|
||||||
const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight;
|
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 }[]) =>
|
const toOverviewPath = (points: { x: number; y: number }[]) =>
|
||||||
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
|
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 assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
|
||||||
const reference = findParrotWeightReference(bird.species);
|
const reference = findParrotWeightReference(bird.species);
|
||||||
|
|
||||||
@@ -896,6 +969,8 @@ function App() {
|
|||||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||||
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
|
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
|
||||||
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
|
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
|
||||||
@@ -961,9 +1036,23 @@ function App() {
|
|||||||
[birds, editingBirdId],
|
[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(
|
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);
|
const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
|
||||||
@@ -984,25 +1073,53 @@ function App() {
|
|||||||
[birds],
|
[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(() => {
|
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.';
|
return 'Needs a few more entries before trend detection.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = weights[0].weightGrams;
|
const first = visibleWeights[0].weightGrams;
|
||||||
const last = weights[weights.length - 1].weightGrams;
|
const last = visibleWeights[visibleWeights.length - 1].weightGrams;
|
||||||
const delta = last - first;
|
const delta = last - first;
|
||||||
|
|
||||||
if (Math.abs(delta) < 1) {
|
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
|
return delta > 0
|
||||||
? `Weight is up ${delta.toFixed(1)} g over the current window.`
|
? `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 window.`;
|
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current 30-day window.`;
|
||||||
}, [weights]);
|
}, [overviewWindowStartDate, weights]);
|
||||||
|
|
||||||
const outOfRangeBirds = useMemo(
|
const activeOutOfRangeBirds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
birds
|
birds
|
||||||
.map((bird) => {
|
.map((bird) => {
|
||||||
@@ -1021,7 +1138,15 @@ function App() {
|
|||||||
[birdWeightAssessments, birds],
|
[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
|
birds
|
||||||
.map((bird) => {
|
.map((bird) => {
|
||||||
@@ -1057,8 +1182,43 @@ function App() {
|
|||||||
[allBirdWeights, birds],
|
[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 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 filteredSpeciesOptions = useMemo(() => {
|
||||||
const query = birdForm.species.trim().toLowerCase();
|
const query = birdForm.species.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -1072,66 +1232,86 @@ function App() {
|
|||||||
}, [birdForm.species]);
|
}, [birdForm.species]);
|
||||||
|
|
||||||
const selectedBirdChart = useMemo(() => {
|
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 {
|
return {
|
||||||
points: [] as { id: string; x: number; y: number; label: string }[],
|
points: [] as { id: string; x: number; y: number; label: string }[],
|
||||||
|
historicalPoints: [] as { id: string; x: number; y: number; label: string }[],
|
||||||
path: '',
|
path: '',
|
||||||
|
historicalPath: '',
|
||||||
isFlat: false,
|
isFlat: false,
|
||||||
|
historicalIsFlat: false,
|
||||||
yTicks: [] as { label: string; y: number }[],
|
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 allPlottedWeights = [...visibleWeights, ...historicalWeights];
|
||||||
const rawMaxWeight = Math.max(...weights.map((entry) => entry.weightGrams));
|
const rawMinWeight = Math.min(...allPlottedWeights.map((entry) => entry.weightGrams));
|
||||||
const isFlat = Math.abs(rawMaxWeight - rawMinWeight) < 0.01;
|
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 padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
||||||
const minWeight = Math.max(0, rawMinWeight - padding);
|
const minWeight = Math.max(0, rawMinWeight - padding);
|
||||||
const maxWeight = rawMaxWeight + 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 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 startMs = startDate.getTime();
|
||||||
const endMs = endDate.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 path = toOverviewPath(points);
|
||||||
|
const historicalPath = toOverviewPath(historicalPoints);
|
||||||
const midWeight = minWeight + (maxWeight - minWeight) / 2;
|
const midWeight = minWeight + (maxWeight - minWeight) / 2;
|
||||||
const midDate = new Date((startMs + endMs) / 2);
|
const midDate = new Date((startMs + endMs) / 2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points,
|
points,
|
||||||
|
historicalPoints,
|
||||||
path,
|
path,
|
||||||
|
historicalPath,
|
||||||
isFlat,
|
isFlat,
|
||||||
|
historicalIsFlat,
|
||||||
yTicks: [
|
yTicks: [
|
||||||
{ label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top },
|
{ label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top },
|
||||||
{ label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 },
|
{ 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 },
|
{ label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom },
|
||||||
],
|
],
|
||||||
xTicks: [
|
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(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]);
|
}, [weights]);
|
||||||
|
|
||||||
const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0;
|
const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0;
|
||||||
|
const hasSelectedBirdHistoricalLine = selectedBirdChart.historicalPoints.length >= 2 && selectedBirdChart.historicalPath.length > 0;
|
||||||
|
|
||||||
const flockWeeklyTrendItems = useMemo(() => {
|
const flockWeeklyTrendItems = useMemo(() => {
|
||||||
return birds
|
return birds
|
||||||
@@ -1169,19 +1349,32 @@ function App() {
|
|||||||
}, [allBirdWeights, birds]);
|
}, [allBirdWeights, birds]);
|
||||||
|
|
||||||
const overviewChart = useMemo(() => {
|
const overviewChart = useMemo(() => {
|
||||||
const plottedBirds = birds
|
|
||||||
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] }))
|
|
||||||
.filter((entry) => entry.weights.length > 0);
|
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
endDate.setHours(0, 0, 0, 0);
|
endDate.setHours(0, 0, 0, 0);
|
||||||
const startDate = new Date(endDate);
|
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) {
|
if (!plottedBirds.length) {
|
||||||
return {
|
return {
|
||||||
plottedBirds,
|
plottedBirds,
|
||||||
series: [],
|
series: [],
|
||||||
|
historicalSeries: [],
|
||||||
xTicks: [
|
xTicks: [
|
||||||
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
|
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
|
||||||
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
|
{ 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 rawMinWeight = Math.min(...allWeights);
|
||||||
const rawMaxWeight = Math.max(...allWeights);
|
const rawMaxWeight = Math.max(...allWeights);
|
||||||
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
||||||
@@ -1204,6 +1399,12 @@ function App() {
|
|||||||
bird,
|
bird,
|
||||||
points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate),
|
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: [
|
xTicks: [
|
||||||
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
|
{ 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 },
|
{ 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 },
|
{ 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) => {
|
const applySession = (session: AuthSessionPayload, token: string) => {
|
||||||
setAuthToken(token);
|
setAuthToken(token);
|
||||||
@@ -1255,6 +1458,7 @@ function App() {
|
|||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
setAllBirdWeights({});
|
setAllBirdWeights({});
|
||||||
|
setAllBirdVetVisits({});
|
||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setWorkspaceForm(emptyWorkspaceForm);
|
setWorkspaceForm(emptyWorkspaceForm);
|
||||||
@@ -1426,7 +1630,7 @@ function App() {
|
|||||||
const loadBirdDetail = async () => {
|
const loadBirdDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const [weightsResponse, visitsResponse] = await Promise.all([
|
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),
|
apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1438,7 +1642,12 @@ function App() {
|
|||||||
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
|
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
|
||||||
|
|
||||||
setWeights(weightsData.weights ?? []);
|
setWeights(weightsData.weights ?? []);
|
||||||
setVetVisits(visitsData.vetVisits ?? []);
|
const nextVetVisits = visitsData.vetVisits ?? [];
|
||||||
|
setVetVisits(nextVetVisits);
|
||||||
|
setAllBirdVetVisits((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedBird.id]: nextVetVisits,
|
||||||
|
}));
|
||||||
setEditingVetVisitId('');
|
setEditingVetVisitId('');
|
||||||
setDeletingVetVisitId('');
|
setDeletingVetVisitId('');
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
@@ -1459,7 +1668,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const responses = await Promise.all(
|
const responses = await Promise.all(
|
||||||
birds.map(async (bird) => {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
|
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
|
||||||
@@ -1479,6 +1688,36 @@ function App() {
|
|||||||
void loadAllBirdWeights();
|
void loadAllBirdWeights();
|
||||||
}, [authToken, birds]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!editingBirdId) {
|
if (!editingBirdId) {
|
||||||
return;
|
return;
|
||||||
@@ -1634,6 +1873,7 @@ function App() {
|
|||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setAllBirdVetVisits({});
|
||||||
setActivePage('overview');
|
setActivePage('overview');
|
||||||
} catch (switchError) {
|
} catch (switchError) {
|
||||||
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
|
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
|
||||||
@@ -2161,11 +2401,16 @@ function App() {
|
|||||||
if (!data?.vetVisit) {
|
if (!data?.vetVisit) {
|
||||||
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
|
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
|
||||||
}
|
}
|
||||||
setVetVisits((current) =>
|
const nextVetVisits = (
|
||||||
(isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort(
|
isEditingVetVisit ? vetVisits.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...vetVisits]
|
||||||
|
).sort(
|
||||||
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
|
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
setVetVisits(nextVetVisits);
|
||||||
|
setAllBirdVetVisits((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedBird.id]: nextVetVisits,
|
||||||
|
}));
|
||||||
setVetVisitForm({
|
setVetVisitForm({
|
||||||
visitedOn: new Date().toISOString().slice(0, 10),
|
visitedOn: new Date().toISOString().slice(0, 10),
|
||||||
clinicName: '',
|
clinicName: '',
|
||||||
@@ -2216,7 +2461,12 @@ function App() {
|
|||||||
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
|
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) {
|
if (editingVetVisitId === visitId) {
|
||||||
handleCancelVetVisitEdit();
|
handleCancelVetVisitEdit();
|
||||||
}
|
}
|
||||||
@@ -2259,6 +2509,11 @@ function App() {
|
|||||||
delete next[selectedBird.id];
|
delete next[selectedBird.id];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setAllBirdVetVisits((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[selectedBird.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
@@ -2329,6 +2584,11 @@ function App() {
|
|||||||
delete next[flockTransferForm.birdId];
|
delete next[flockTransferForm.birdId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setAllBirdVetVisits((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[flockTransferForm.birdId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
if (selectedBird?.id === flockTransferForm.birdId) {
|
if (selectedBird?.id === flockTransferForm.birdId) {
|
||||||
@@ -2638,6 +2898,18 @@ function App() {
|
|||||||
setShowWeightAlertModal(true);
|
setShowWeightAlertModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVetVisitReminderClick = () => {
|
||||||
|
const firstDueBird = vetVisitDueBirds[0];
|
||||||
|
|
||||||
|
if (!firstDueBird) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedBirdId(firstDueBird.id);
|
||||||
|
setBulkWeightOpen(false);
|
||||||
|
setActivePage('flock');
|
||||||
|
};
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
@@ -2867,7 +3139,10 @@ function App() {
|
|||||||
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
|
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2899,6 +3174,25 @@ function App() {
|
|||||||
{tick.label}
|
{tick.label}
|
||||||
</text>
|
</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 }) => (
|
{overviewChart.series.map(({ bird, points }) => (
|
||||||
<g key={bird.id}>
|
<g key={bird.id}>
|
||||||
{points.length > 1 ? (
|
{points.length > 1 ? (
|
||||||
@@ -2916,11 +3210,14 @@ function App() {
|
|||||||
|
|
||||||
<div className="legend-grid">
|
<div className="legend-grid">
|
||||||
{overviewChart.plottedBirds.map(({ bird }) => {
|
{overviewChart.plottedBirds.map(({ bird }) => {
|
||||||
|
const hasHistoricalData = overviewChart.historicalSeries.some((entry) => entry.bird.id === bird.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={bird.id} className="legend-card">
|
<article key={bird.id} className="legend-card">
|
||||||
<span className="legend-swatch" style={{ background: bird.chartColor }} />
|
<span className="legend-swatch" style={{ background: bird.chartColor }} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{bird.name}</strong>
|
<strong>{bird.name}</strong>
|
||||||
|
{hasHistoricalData ? <span>Solid current, dashed previous year</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -2963,6 +3260,21 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : 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">
|
<article className="summary-card">
|
||||||
<span>Weekly flock changes</span>
|
<span>Weekly flock changes</span>
|
||||||
{flockWeeklyTrendItems.length ? (
|
{flockWeeklyTrendItems.length ? (
|
||||||
@@ -3108,14 +3420,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bird-list">
|
<div className="bird-list">
|
||||||
{birds.map((bird) => (
|
{birds.map((bird) => {
|
||||||
<button
|
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}
|
key={bird.id}
|
||||||
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
|
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}
|
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">
|
<div className="bird-card-header">
|
||||||
{bird.photoDataUrl ? (
|
{bird.photoDataUrl ? (
|
||||||
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
|
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
|
||||||
@@ -3135,13 +3451,46 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
||||||
{birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? (
|
</button>
|
||||||
|
{weightRangeAlert || hasVetVisitAlert ? (
|
||||||
|
<div className="bird-alert-stack">
|
||||||
|
{weightRangeAlert ? (
|
||||||
<span className="bird-alert-badge">
|
<span className="bird-alert-badge">
|
||||||
{birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'}
|
{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>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</button>
|
||||||
))}
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -3342,6 +3691,42 @@ function App() {
|
|||||||
{tick.label}
|
{tick.label}
|
||||||
</text>
|
</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 ? (
|
{hasSelectedBirdLine && selectedBirdChart.isFlat ? (
|
||||||
<line
|
<line
|
||||||
x1={selectedBirdChart.points[0].x}
|
x1={selectedBirdChart.points[0].x}
|
||||||
@@ -3364,7 +3749,13 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<div className="chart-footer">
|
<div className="chart-footer">
|
||||||
<p>{selectedBirdTrendCopy}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4386,6 +4777,13 @@ function App() {
|
|||||||
{bird.species} • Latest weight {formatWeight(bird.latestWeightGrams)} • Typical range{' '}
|
{bird.species} • Latest weight {formatWeight(bird.latestWeightGrams)} • Typical range{' '}
|
||||||
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
|
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className="range-alert-button"
|
||||||
|
onClick={() => dismissAlert(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Dismiss for {bird.name}
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => (
|
{weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => (
|
||||||
@@ -4395,6 +4793,24 @@ function App() {
|
|||||||
{formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '}
|
{formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '}
|
||||||
{formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)}
|
{formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)}
|
||||||
</span>
|
</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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+65
-1
@@ -737,7 +737,6 @@ textarea {
|
|||||||
|
|
||||||
.bird-card {
|
.bird-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid rgba(95, 121, 77, 0.16);
|
border: 1px solid rgba(95, 121, 77, 0.16);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -746,9 +745,33 @@ textarea {
|
|||||||
background: var(--card-bg);
|
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 {
|
.bird-alert-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
padding: 0.28rem 0.7rem;
|
padding: 0.28rem 0.7rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -761,6 +784,28 @@ textarea {
|
|||||||
text-transform: uppercase;
|
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:hover,
|
||||||
.bird-card.active {
|
.bird-card.active {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -807,6 +852,15 @@ textarea {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-weight-line,
|
||||||
|
.historical-weight-dot {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-weight-line {
|
||||||
|
stroke-dasharray: 7 6;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-footer,
|
.chart-footer,
|
||||||
.recent-list,
|
.recent-list,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
@@ -959,6 +1013,16 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legend-card div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-card div span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.legend-swatch {
|
.legend-swatch {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user