feat: warn on the dashboard when a swarm check is overdue

Schwarmkontrolle already captures nextCheckDate per check, but it was
only ever visible by opening that one colony's tab. During swarm
season a missed follow-up check can mean losing half the colony, so
this now surfaces as a dashboard alert (OVERDUE_SWARM_CHECK) like
overdue tasks already do - only the most recent check per colony is
considered, so a completed follow-up clears the warning.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-06 02:35:17 +02:00
1 parent acbf5f16ce
commit 0881a38800
4 files changed
+88 -3

No files matched your search

@@ -10,6 +10,7 @@ describe('DashboardService', () => {
task: { count: jest.Mock; findMany: jest.Mock }; task: { count: jest.Mock; findMany: jest.Mock };
honeyBatch: { count: jest.Mock }; honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock }; stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
}; };
beforeEach(() => { beforeEach(() => {
@@ -26,6 +27,7 @@ describe('DashboardService', () => {
}, },
honeyBatch: { count: jest.fn().mockResolvedValue(1) }, honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) }, stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
}; };
service = new DashboardService(prisma as unknown as PrismaService); service = new DashboardService(prisma as unknown as PrismaService);
}); });
@@ -63,4 +65,46 @@ describe('DashboardService', () => {
'LOW_STOCK', 'LOW_STOCK',
]); ]);
}); });
it('flags a colony whose most recent swarm check is overdue', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
nextCheckDate: new Date('2020-01-01'),
colony: { code: 'V-001' },
},
]);
const alerts = await service.alerts();
expect(alerts).toEqual([
{
type: 'OVERDUE_SWARM_CHECK',
message: 'Schwarmkontrolle für Volk V-001 überfällig',
relatedType: 'Colony',
relatedId: 'colony-1',
},
]);
});
it('ignores an overdue swarm check when a newer check already superseded it', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: new Date('2026-06-10'),
nextCheckDate: null,
colony: { code: 'V-001' },
},
{
colonyId: 'colony-1',
date: new Date('2026-06-01'),
nextCheckDate: new Date('2020-01-01'),
colony: { code: 'V-001' },
},
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(0);
});
}); });
@@ -11,7 +11,8 @@ export interface DashboardSummary {
} }
export interface DashboardAlert { export interface DashboardAlert {
type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK'; type:
'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK' | 'OVERDUE_SWARM_CHECK';
message: string; message: string;
relatedType: string; relatedType: string;
relatedId: string; relatedId: string;
@@ -60,7 +61,8 @@ export class DashboardService {
} }
async alerts(): Promise<DashboardAlert[]> { async alerts(): Promise<DashboardAlert[]> {
const [overdueTasks, attentionColonies, stockItems] = await Promise.all([ const [overdueTasks, attentionColonies, stockItems, swarmChecks] =
await Promise.all([
this.prisma.task.findMany({ this.prisma.task.findMany({
where: { where: {
status: { in: ['OPEN', 'IN_PROGRESS'] }, status: { in: ['OPEN', 'IN_PROGRESS'] },
@@ -73,6 +75,15 @@ export class DashboardService {
this.prisma.stockItem.findMany({ this.prisma.stockItem.findMany({
where: { minThreshold: { not: null } }, 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 alerts: DashboardAlert[] = []; const alerts: DashboardAlert[] = [];
@@ -102,6 +113,26 @@ export class DashboardService {
}); });
} }
} }
// swarmChecks is ordered by colonyId then date desc, so the first entry
// seen per colonyId is that colony's most recent check - only the most
// recent nextCheckDate matters, an older overdue one has been superseded.
const now = new Date();
const latestCheckByColony = new Map<string, (typeof swarmChecks)[number]>();
for (const check of swarmChecks) {
if (!latestCheckByColony.has(check.colonyId)) {
latestCheckByColony.set(check.colonyId, check);
}
}
for (const check of latestCheckByColony.values()) {
if (check.nextCheckDate && check.nextCheckDate < now) {
alerts.push({
type: 'OVERDUE_SWARM_CHECK',
message: `Schwarmkontrolle für Volk ${check.colony.code} überfällig`,
relatedType: 'Colony',
relatedId: check.colonyId,
});
}
}
return alerts; return alerts;
} }
} }
@@ -12,6 +12,7 @@ import {
Layers, Layers,
PackageSearch, PackageSearch,
ShieldAlert, ShieldAlert,
Sprout,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
@@ -60,6 +61,11 @@ const ALERT_META: Record<
icon: PackageSearch, icon: PackageSearch,
className: "border-warning/40 bg-warning/10", className: "border-warning/40 bg-warning/10",
}, },
OVERDUE_SWARM_CHECK: {
label: "Schwarmkontrolle überfällig",
icon: Sprout,
className: "border-destructive/30 bg-destructive/5",
},
}; };
export default function DashboardPage() { export default function DashboardPage() {
+5 -1
View File
@@ -7,7 +7,11 @@ export interface DashboardSummary {
stockItemsLowCount: number; stockItemsLowCount: number;
} }
export type DashboardAlertType = "OVERDUE_TASK" | "COLONY_ATTENTION" | "LOW_STOCK"; export type DashboardAlertType =
| "OVERDUE_TASK"
| "COLONY_ATTENTION"
| "LOW_STOCK"
| "OVERDUE_SWARM_CHECK";
export interface DashboardAlert { export interface DashboardAlert {
type: DashboardAlertType; type: DashboardAlertType;