From edf13d3ae4dd0cbe0ef37e9e6b1a648dd05009e3 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 6 Sep 2026 18:43:37 +0200 Subject: [PATCH] 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 --- .../src/dashboard/dashboard.service.spec.ts | 31 +++++++ .../src/dashboard/dashboard.service.ts | 91 ++++++++++++++----- .../src/app/(app)/dashboard/page.tsx | 5 + ApiCore-Frontend/src/lib/dashboard/types.ts | 3 +- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts index 247187b..b0448f9 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts @@ -107,4 +107,35 @@ describe('DashboardService', () => { 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); + }); + }); }); diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.ts b/ApiCore-Backend/src/dashboard/dashboard.service.ts index 73ffeeb..9f7ecea 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.ts @@ -12,13 +12,21 @@ export interface DashboardSummary { export interface DashboardAlert { type: - 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK' | 'OVERDUE_SWARM_CHECK'; + | 'OVERDUE_TASK' + | 'COLONY_ATTENTION' + | 'LOW_STOCK' + | 'OVERDUE_SWARM_CHECK' + | 'OLD_QUEEN'; message: string; relatedType: string; relatedId: string; } 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() export class DashboardService { @@ -61,30 +69,46 @@ export class DashboardService { } async alerts(): Promise { - const [overdueTasks, attentionColonies, stockItems, swarmChecks] = - await Promise.all([ - this.prisma.task.findMany({ - where: { - status: { in: ['OPEN', 'IN_PROGRESS'] }, - dueDate: { lt: new Date() }, - }, - }), - this.prisma.colony.findMany({ - where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, - }), - this.prisma.stockItem.findMany({ - where: { minThreshold: { not: null } }, - }), - this.prisma.swarmControlCheck.findMany({ - where: { colony: { status: 'ACTIVE' } }, - orderBy: [{ colonyId: 'asc' }, { date: 'desc' }], - select: { - colonyId: true, - nextCheckDate: true, - colony: { select: { code: true } }, - }, - }), - ]); + const [ + overdueTasks, + attentionColonies, + stockItems, + swarmChecks, + queenedColonies, + ] = await Promise.all([ + this.prisma.task.findMany({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + dueDate: { lt: new Date() }, + }, + }), + this.prisma.colony.findMany({ + where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, + }), + this.prisma.stockItem.findMany({ + where: { minThreshold: { not: null } }, + }), + this.prisma.swarmControlCheck.findMany({ + 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[] = []; 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; } } diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index 2a1e76d..7d74e3d 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -66,6 +66,11 @@ const ALERT_META: Record< icon: Sprout, 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() { diff --git a/ApiCore-Frontend/src/lib/dashboard/types.ts b/ApiCore-Frontend/src/lib/dashboard/types.ts index 61c8260..716a21a 100644 --- a/ApiCore-Frontend/src/lib/dashboard/types.ts +++ b/ApiCore-Frontend/src/lib/dashboard/types.ts @@ -11,7 +11,8 @@ export type DashboardAlertType = | "OVERDUE_TASK" | "COLONY_ATTENTION" | "LOW_STOCK" - | "OVERDUE_SWARM_CHECK"; + | "OVERDUE_SWARM_CHECK" + | "OLD_QUEEN"; export interface DashboardAlert { type: DashboardAlertType;