feat: warn on the dashboard when a queen is 3+ years old

Queen.hatchYear was already captured but never used anywhere - no
signal existed for "this colony's queen is overdue for evaluation or
replacement," despite that being one of the most common productivity
and swarming risk factors in beekeeping. Adds an OLD_QUEEN dashboard
alert alongside the existing overdue-task/swarm-check ones, flagging
any active colony whose current queen has completed 3+ seasons.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-06 18:43:37 +02:00
1 parent 8c4ae64e76
commit edf13d3ae4
4 files changed
+104 -26

No files matched your search

@@ -107,4 +107,35 @@ describe('DashboardService', () => {
expect(alerts).toHaveLength(0); expect(alerts).toHaveLength(0);
}); });
describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => {
prisma.colony.findMany
.mockResolvedValueOnce([]) // attentionColonies
.mockResolvedValueOnce([
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(1);
expect(alerts[0].type).toBe('OLD_QUEEN');
expect(alerts[0].relatedId).toBe('colony-1');
});
it('does not flag a queen younger than the threshold', async () => {
const currentYear = new Date().getFullYear();
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'colony-1',
code: 'V-001',
currentQueen: { hatchYear: currentYear },
},
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(0);
});
});
}); });
@@ -12,13 +12,21 @@ export interface DashboardSummary {
export interface DashboardAlert { export interface DashboardAlert {
type: type:
'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK' | 'OVERDUE_SWARM_CHECK'; | 'OVERDUE_TASK'
| 'COLONY_ATTENTION'
| 'LOW_STOCK'
| 'OVERDUE_SWARM_CHECK'
| 'OLD_QUEEN';
message: string; message: string;
relatedType: string; relatedType: string;
relatedId: string; relatedId: string;
} }
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const; const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
// Queens rarely stay reliably productive past their third season - laying
// rate and swarming risk both worsen well before then, so this flags a
// colony for evaluation rather than demanding immediate requeening.
const OLD_QUEEN_AGE_YEARS = 3;
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
@@ -61,30 +69,46 @@ export class DashboardService {
} }
async alerts(): Promise<DashboardAlert[]> { async alerts(): Promise<DashboardAlert[]> {
const [overdueTasks, attentionColonies, stockItems, swarmChecks] = const [
await Promise.all([ overdueTasks,
this.prisma.task.findMany({ attentionColonies,
where: { stockItems,
status: { in: ['OPEN', 'IN_PROGRESS'] }, swarmChecks,
dueDate: { lt: new Date() }, queenedColonies,
}, ] = await Promise.all([
}), this.prisma.task.findMany({
this.prisma.colony.findMany({ where: {
where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, status: { in: ['OPEN', 'IN_PROGRESS'] },
}), dueDate: { lt: new Date() },
this.prisma.stockItem.findMany({ },
where: { minThreshold: { not: null } }, }),
}), this.prisma.colony.findMany({
this.prisma.swarmControlCheck.findMany({ where: { status: { in: [...ATTENTION_COLONY_STATUSES] } },
where: { colony: { status: 'ACTIVE' } }, }),
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }], this.prisma.stockItem.findMany({
select: { where: { minThreshold: { not: null } },
colonyId: true, }),
nextCheckDate: true, this.prisma.swarmControlCheck.findMany({
colony: { select: { code: true } }, where: { colony: { status: 'ACTIVE' } },
}, orderBy: [{ colonyId: 'asc' }, { date: 'desc' }],
}), select: {
]); colonyId: true,
nextCheckDate: true,
colony: { select: { code: true } },
},
}),
this.prisma.colony.findMany({
where: {
status: 'ACTIVE',
currentQueen: { hatchYear: { not: null } },
},
select: {
id: true,
code: true,
currentQueen: { select: { hatchYear: true } },
},
}),
]);
const alerts: DashboardAlert[] = []; const alerts: DashboardAlert[] = [];
for (const task of overdueTasks) { for (const task of overdueTasks) {
@@ -133,6 +157,23 @@ export class DashboardService {
}); });
} }
} }
const currentYear = now.getFullYear();
for (const colony of queenedColonies) {
// The where clause above already requires currentQueen and its
// hatchYear to exist, but the relation is still nullable in the type.
if (!colony.currentQueen?.hatchYear) {
continue;
}
const ageYears = currentYear - colony.currentQueen.hatchYear;
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
alerts.push({
type: 'OLD_QUEEN',
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
relatedType: 'Colony',
relatedId: colony.id,
});
}
}
return alerts; return alerts;
} }
} }
@@ -66,6 +66,11 @@ const ALERT_META: Record<
icon: Sprout, icon: Sprout,
className: "border-destructive/30 bg-destructive/5", className: "border-destructive/30 bg-destructive/5",
}, },
OLD_QUEEN: {
label: "Königin überaltert",
icon: Crown,
className: "border-warning/40 bg-warning/10",
},
}; };
export default function DashboardPage() { export default function DashboardPage() {
+2 -1
View File
@@ -11,7 +11,8 @@ export type DashboardAlertType =
| "OVERDUE_TASK" | "OVERDUE_TASK"
| "COLONY_ATTENTION" | "COLONY_ATTENTION"
| "LOW_STOCK" | "LOW_STOCK"
| "OVERDUE_SWARM_CHECK"; | "OVERDUE_SWARM_CHECK"
| "OLD_QUEEN";
export interface DashboardAlert { export interface DashboardAlert {
type: DashboardAlertType; type: DashboardAlertType;