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
+101 -16

No files matched your search

@@ -10,6 +10,7 @@ describe('DashboardService', () => {
task: { count: jest.Mock; findMany: jest.Mock };
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
};
beforeEach(() => {
@@ -26,6 +27,7 @@ describe('DashboardService', () => {
},
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
@@ -63,4 +65,46 @@ describe('DashboardService', () => {
'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 {
type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK';
type:
'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK' | 'OVERDUE_SWARM_CHECK';
message: string;
relatedType: string;
relatedId: string;
@@ -60,20 +61,30 @@ export class DashboardService {
}
async alerts(): Promise<DashboardAlert[]> {
const [overdueTasks, attentionColonies, stockItems] = 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 } },
}),
]);
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 alerts: DashboardAlert[] = [];
for (const task of overdueTasks) {
@@ -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;
}
}
@@ -12,6 +12,7 @@ import {
Layers,
PackageSearch,
ShieldAlert,
Sprout,
} from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
@@ -60,6 +61,11 @@ const ALERT_META: Record<
icon: PackageSearch,
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() {
+5 -1
View File
@@ -7,7 +7,11 @@ export interface DashboardSummary {
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 {
type: DashboardAlertType;