fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s

- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 18:26:09 +02:00
1 parent 32c61c8e14
commit c2f71bcb03
101 files changed
+2125 -207

No files matched your search

+1 -1
View File
@@ -340,7 +340,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
### Jobs
**Aufruf.** `node dist/cli.js <job> [args]` (`src/cli.ts`, Rolle `job`) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt.
**Aufruf.** `APICORE_ROLE=job node dist/cli.js <job> [args]`, etwa `APICORE_ROLE=job node dist/cli.js daily-evaluation` (`src/cli.ts`; die Rolle muss `job` sein, `all` und `api` werden abgelehnt, und eine Form wie `cli.js jobs daily-evaluation` scheitert mit „Unknown job“; im Cluster setzt das CronJob-Manifest die Rolle) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt.
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
@@ -699,7 +699,8 @@ describe('BottlingService', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.07.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'c1' }],
};
@@ -3,6 +3,14 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('CalendarService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: CalendarService;
let prisma: {
task: { findMany: jest.Mock };
@@ -11,6 +19,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
colony: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
};
let resolver: { getPermissions: jest.Mock };
@@ -24,6 +34,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new CalendarService(
prisma as unknown as PrismaService,
@@ -44,7 +56,12 @@ describe('CalendarService', () => {
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{ id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' },
{
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]);
const events = await service.getEvents(
@@ -62,6 +79,73 @@ describe('CalendarService', () => {
]);
});
it('names the colony or apiary of every event in context', async () => {
prisma.task.findMany.mockResolvedValueOnce([
{
id: 't1',
title: 'Füttern',
dueDate: new Date('2026-06-05'),
relatedType: 'COLONY',
relatedId: 'c1',
},
{
id: 't2',
title: 'Stand putzen',
dueDate: new Date('2026-06-06'),
relatedType: 'APIARY',
relatedId: 'a1',
},
{
id: 't3',
title: 'Allgemein',
dueDate: new Date('2026-06-07'),
relatedType: 'GENERAL',
relatedId: null,
},
]);
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-001' }]);
prisma.apiary.findMany.mockResolvedValueOnce([
{ id: 'a1', name: 'Teststand Nord' },
]);
prisma.inspection.findMany.mockResolvedValueOnce([
{
id: 'i1',
date: new Date('2026-06-01'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.feeding.findMany.mockResolvedValueOnce([
{
id: 'f1',
date: new Date('2026-06-02'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]);
const events = await service.getEvents(
{ from: new Date('2026-06-01'), to: new Date('2026-06-30') },
'user-1',
);
const contextOf = (id: string) => events.find((e) => e.id === id)?.context;
expect(contextOf('i1')).toBe('V-001');
expect(contextOf('f1')).toBe('V-001');
expect(contextOf('t1')).toBe('V-001');
expect(contextOf('t2')).toBe('Teststand Nord');
expect(contextOf('t3')).toBeUndefined();
expect(contextOf('h1')).toBe('Teststand Nord');
});
it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents(
{
@@ -135,6 +219,9 @@ describe('CalendarService', () => {
title: 'Wartezeit V-001 · Oxuvar',
relatedType: 'Colony',
relatedId: 'c1',
context: 'V-001',
product: 'Oxuvar',
detail: 'bis einschließlich 29.06. – ab 30.06. wieder erntefähig',
},
]);
});
@@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
type TreatmentWithColony = Prisma.TreatmentGetPayload<{
include: { colony: true };
@@ -25,6 +29,12 @@ export interface CalendarEvent {
/** WITHDRAWAL only: the last day the span covers (inclusive). */
endDate?: Date;
title: string;
/** Colony code or apiary name the event belongs to; absent for general tasks. */
context?: string;
/** WITHDRAWAL only: the treatment product. */
product?: string;
/** WITHDRAWAL only: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
detail?: string;
relatedType: string;
relatedId: string;
}
@@ -71,7 +81,7 @@ export class CalendarService {
if (span === null) {
return [];
}
const lastDay = addDays(span.untilDay, -1);
const lastDay = lastBlockedDay(span.untilDay);
if (span.fromDay > toDay || lastDay < fromDay) {
return [];
}
@@ -82,6 +92,9 @@ export class CalendarService {
date: dayToDate(span.fromDay),
endDate: dayToDate(lastDay),
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
product: treatment.product,
detail: withdrawalRangeText(span.untilDay),
relatedType: 'Colony',
relatedId: treatment.colonyId,
},
@@ -89,6 +102,60 @@ export class CalendarService {
});
}
/**
* Colony code or apiary name of the tasks that point at one. By ruling only
* COLONY and APIARY tasks get a context; other tasks (queen, honey batch,
* storage, general) have none.
*/
private async taskContexts(
tasks: readonly {
id: string;
relatedType: string;
relatedId: string | null;
}[],
): Promise<Map<string, string>> {
const idsOf = (type: string) => [
...new Set(
tasks.flatMap((task) =>
task.relatedType === type && task.relatedId !== null
? [task.relatedId]
: [],
),
),
];
const colonyIds = idsOf('COLONY');
const apiaryIds = idsOf('APIARY');
const noColonies: { id: string; code: string }[] = [];
const noApiaries: { id: string; name: string }[] = [];
const [colonies, apiaries] = await Promise.all([
colonyIds.length === 0
? Promise.resolve(noColonies)
: this.prisma.colony.findMany({
where: { id: { in: colonyIds } },
select: { id: true, code: true },
}),
apiaryIds.length === 0
? Promise.resolve(noApiaries)
: this.prisma.apiary.findMany({
where: { id: { in: apiaryIds } },
select: { id: true, name: true },
}),
]);
const names = new Map<string, string>([
...colonies.map((c) => [c.id, c.code] as const),
...apiaries.map((a) => [a.id, a.name] as const),
]);
const contexts = new Map<string, string>();
for (const task of tasks) {
const name =
task.relatedId === null ? undefined : names.get(task.relatedId);
if (name !== undefined) {
contexts.set(task.id, name);
}
}
return contexts;
}
async getEvents(
query: CalendarQuery,
userId: string,
@@ -132,6 +199,7 @@ export class CalendarService {
date: { gte: from, lte: to },
...(apiaryId ? { apiaryId } : {}),
},
include: { apiary: { select: { name: true } } },
}),
this.prisma.queenColonyHistory.findMany({
where: { startDate: { gte: from, lte: to }, ...colonyFilter },
@@ -140,6 +208,8 @@ export class CalendarService {
this.withdrawalEvents(query, canReadTreatments),
]);
const taskContexts = await this.taskContexts(tasks);
const events: CalendarEvent[] = [
...tasks
.filter((task) => task.dueDate !== null)
@@ -148,6 +218,7 @@ export class CalendarService {
type: 'TASK' as const,
date: task.dueDate!,
title: task.title,
context: taskContexts.get(task.id),
relatedType: 'Task',
relatedId: task.id,
})),
@@ -156,6 +227,7 @@ export class CalendarService {
type: 'INSPECTION' as const,
date: inspection.date,
title: `Durchsicht ${inspection.colony.code}`,
context: inspection.colony.code,
relatedType: 'Colony',
relatedId: inspection.colonyId,
})),
@@ -164,6 +236,7 @@ export class CalendarService {
type: 'TREATMENT' as const,
date: treatment.date,
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
relatedType: 'Colony',
relatedId: treatment.colonyId,
})),
@@ -172,6 +245,7 @@ export class CalendarService {
type: 'FEEDING' as const,
date: feeding.date,
title: `Fütterung ${feeding.colony.code}`,
context: feeding.colony.code,
relatedType: 'Colony',
relatedId: feeding.colonyId,
})),
@@ -180,6 +254,7 @@ export class CalendarService {
type: 'HARVEST' as const,
date: harvest.date,
title: 'Ernte',
context: harvest.apiary.name,
relatedType: 'Apiary',
relatedId: harvest.apiaryId,
})),
@@ -188,6 +263,7 @@ export class CalendarService {
type: 'QUEEN_ASSIGNMENT' as const,
date: assignment.startDate,
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
context: assignment.colony.code,
relatedType: 'Queen',
relatedId: assignment.queenId,
})),
@@ -0,0 +1,24 @@
import { endSentence, formatDayDe } from './format-day';
describe('formatDayDe', () => {
it('formats a day without and with the year', () => {
expect(formatDayDe('2026-05-05')).toBe('05.05.');
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
});
});
describe('endSentence', () => {
it('adds the period when the text has none', () => {
expect(endSentence('Weiselzellen gefunden')).toBe('Weiselzellen gefunden.');
});
it('does not double the period a formatted day already ends with', () => {
expect(endSentence(`Ableger von V-1 am ${formatDayDe('2026-10-13')}`)).toBe(
'Ableger von V-1 am 13.10.',
);
});
it('keeps a text that already ends with a period', () => {
expect(endSentence('Fertig.')).toBe('Fertig.');
});
});
+13
View File
@@ -0,0 +1,13 @@
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
/**
* Closes a sentence with one period. A day formatted by formatDayDe ends with
* its own dot ('13.10.'), which then is the end of the sentence.
*/
export function endSentence(text: string): string {
return text.endsWith('.') ? text : `${text}.`;
}
@@ -23,7 +23,8 @@ describe('plausibility warnings', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'c1' }],
};
@@ -260,7 +260,8 @@ describe('HarvestsService', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'V-1: Wartezeit nach Oxuvar endet am 20.07.2026.',
message:
'V-1: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'colony-1' }],
};
@@ -106,5 +106,5 @@ export function renderSuggestion(
return override ? { ...rendered, title: override } : rendered;
}
export { formatDayDe } from './texts/format-day';
export { formatDayDe } from '../common/format-day';
export { formatNumberDe } from './texts/format-number';
@@ -28,3 +28,35 @@ describe('chain texts', () => {
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle');
});
});
describe('chain texts: sentence ends', () => {
const lookup = {
colonyCode: () => 'V-1',
queenCode: () => 'Q-1',
treatmentProduct: () => null,
apiaryName: () => null,
stockItemName: () => null,
};
it('never ends a reason with two periods when a day closes the sentence', () => {
for (const code of Object.keys(CHAIN_TEXTS)) {
const reasons = CHAIN_TEXTS[code].render(
CHAIN_TEXTS[code].example,
lookup,
'2026-05-05',
).reasons;
for (const reason of reasons) {
expect(reason).not.toMatch(/\.\./);
}
}
});
it('writes the split day with a single period', () => {
const reasons = CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.render(
CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.example,
lookup,
'2026-05-05',
).reasons;
expect(reasons[0]).toBe('Ableger von V-1 am 01.05.');
});
});
@@ -2,7 +2,7 @@ import type {
SuggestionTextRegister,
SuggestionTextTemplate,
} from '../suggestion-texts';
import { formatDayDe } from './format-day';
import { endSentence, formatDayDe } from '../../common/format-day';
import type { EvidenceLookup } from '../evidence-lookup';
import type { SuggestionEvidence } from '../suggestion.types';
@@ -39,7 +39,9 @@ function splitTemplate(
render: (evidence, lookup) => ({
title,
reasons: [
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`,
endSentence(
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}`,
),
reason,
],
}),
@@ -59,7 +61,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
render: (evidence, lookup) => ({
title: 'Schwarmkontrolle',
reasons: [
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`,
endSentence(
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden`,
),
],
}),
},
@@ -80,7 +84,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
return {
title: 'Annahme der Königin prüfen',
reasons: [
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`,
endSentence(
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt`,
),
],
};
},
@@ -1,5 +1,5 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import { formatDayDe } from './format-day';
import { formatDayDe } from '../../common/format-day';
const ENDED_VERBS: Readonly<Record<string, string>> = {
DECEASED: 'ist eingegangen',
@@ -1,5 +0,0 @@
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
@@ -1,4 +1,4 @@
import { formatDayDe } from './format-day';
import { formatDayDe } from '../../common/format-day';
import { formatNumberDe } from './format-number';
import type {
RenderedSuggestionText,
@@ -6,6 +6,14 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('TreatmentsService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: TreatmentsService;
let prisma: {
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -499,6 +507,9 @@ describe('TreatmentsService', () => {
{
colonyId: 'colony-1',
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Ameisensäure',
},
]);
@@ -587,6 +598,9 @@ describe('TreatmentsService', () => {
{
colonyId: 'colony-1',
activeUntil: '2026-07-27T00:00:00.000Z',
lastBlockedDay: '2026-07-26',
withdrawalText:
'bis einschließlich 26.07. – ab 27.07. wieder erntefähig',
product: 'Ameisensäure',
},
]);
@@ -15,6 +15,10 @@ import {
import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock';
import { berlinDayOf, dayToDate } from '../common/dates';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
import {
isInWindow,
withdrawalWindowOf,
@@ -27,6 +31,10 @@ import { UpdateTreatmentDto } from './dto/update-treatment.dto';
export interface WithdrawalStatus {
colonyId: string;
activeUntil: string;
/** Last blocked day (inclusive), 'YYYY-MM-DD'; activeUntil is the day after. */
lastBlockedDay: string;
/** Ready text: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
withdrawalText: string;
product: string;
}
@@ -116,6 +124,8 @@ export class TreatmentsService {
return [...activeByColony.values()].map((window) => ({
colonyId: window.colonyId,
activeUntil: dayToDate(window.untilDay).toISOString(),
lastBlockedDay: lastBlockedDay(window.untilDay),
withdrawalText: withdrawalRangeText(window.untilDay),
product: window.product,
}));
}
@@ -40,6 +40,14 @@ const conflict = (
});
describe('WithdrawalCheckService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let db: {
harvest: { findMany: jest.Mock };
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -269,7 +277,8 @@ describe('WithdrawalCheckService', () => {
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
refs: [
{ type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' },
@@ -279,6 +288,25 @@ describe('WithdrawalCheckService', () => {
]);
});
it('warns once per colony and treatment when several harvests feed the batch', async () => {
const warnings = await service.warningsFor(asDb(), [
conflict({ harvestId: 'h1' }),
conflict({ harvestId: 'h2' }),
conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }),
]);
expect(warnings.map((w) => w.message)).toEqual([
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
]);
expect(warnings[0].refs).toEqual([
{ type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' },
{ type: 'Harvest', id: 'h1' },
{ type: 'Harvest', id: 'h2' },
]);
});
it('answers nothing without reading when there is no conflict', async () => {
expect(await service.warningsFor(asDb(), [])).toEqual([]);
expect(db.colony.findMany).not.toHaveBeenCalled();
@@ -300,7 +328,7 @@ describe('WithdrawalCheckService', () => {
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message:
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.',
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
relatedType: 'Harvest',
relatedId: 'h1',
link: '/harvests/h1',
@@ -337,7 +365,7 @@ describe('WithdrawalCheckService', () => {
{ message: string; dedupeKeyPrefix: string },
];
expect(input.message).toBe(
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.',
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.',
);
// Same key as without the lots: one notification per harvest and colony.
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
@@ -7,13 +7,14 @@ import {
} from '../common/warnings/plausibility-warning';
import { NotificationsService } from '../notifications/notifications.service';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { formatDayDe } from '../suggestions/texts/format-day';
import { formatDayDe } from '../common/format-day';
import {
isInWindow,
withdrawalWindowOf,
type WithdrawalWindow,
} from '../treatments/withdrawal-window';
import { withdrawalSpanOf } from './withdrawal-span';
import { withdrawalRangeText } from './withdrawal-text';
/** A harvest taken from a colony while a treatment's withdrawal period ran. */
export interface WithdrawalConflict {
@@ -49,6 +50,22 @@ function longestCovering(
);
}
/**
* One group per (colony, treatment): several harvests of a colony that feed
* the same batch yield one warning, not one per harvest. Keeps the order of
* first appearance.
*/
function perColonyAndTreatment(
conflicts: readonly WithdrawalConflict[],
): WithdrawalConflict[][] {
const groups = new Map<string, WithdrawalConflict[]>();
for (const conflict of conflicts) {
const key = `${conflict.colonyId}:${conflict.treatmentId}`;
groups.set(key, [...(groups.get(key) ?? []), conflict]);
}
return [...groups.values()];
}
/**
* Checks harvests against the withdrawal windows (Wartezeit) of their
* colonies. Only the window helper decides what a window is
@@ -190,16 +207,22 @@ export class WithdrawalCheckService {
return [];
}
const codes = await this.colonyCodes(db, conflicts);
return conflicts.map((conflict) => ({
return perColonyAndTreatment(conflicts).map((group) => {
const [first] = group;
return {
code: 'WITHDRAWAL_ACTIVE',
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`,
message: `${codes.get(first.colonyId) ?? first.colonyId}: Wartezeit nach ${first.product} ${withdrawalRangeText(first.untilDay)}.`,
refs: [
{ type: 'Colony', id: conflict.colonyId },
{ type: 'Treatment', id: conflict.treatmentId },
{ type: 'Harvest', id: conflict.harvestId },
{ type: 'Colony', id: first.colonyId },
{ type: 'Treatment', id: first.treatmentId },
...group.map((conflict) => ({
type: 'Harvest',
id: conflict.harvestId,
})),
],
}));
};
});
}
/**
@@ -234,7 +257,7 @@ export class WithdrawalCheckService {
{
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.${bottledText}`,
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} ${withdrawalRangeText(conflict.untilDay)}.${bottledText}`,
relatedType: 'Harvest',
relatedId: conflict.harvestId,
link: `/harvests/${conflict.harvestId}`,
@@ -0,0 +1,37 @@
import { lastBlockedDay, withdrawalRangeText } from './withdrawal-text';
describe('lastBlockedDay', () => {
it('is the day before the first free day', () => {
expect(lastBlockedDay('2026-10-27')).toBe('2026-10-26');
expect(lastBlockedDay('2027-01-01')).toBe('2026-12-31');
});
});
describe('withdrawalRangeText', () => {
const today = '2026-10-04';
it('names the last blocked day (inclusive) and the first free day', () => {
// The window is [fromDay, untilDay): untilDay is the first free day.
expect(withdrawalRangeText('2026-10-27', today)).toBe(
'bis einschließlich 26.10. – ab 27.10. wieder erntefähig',
);
});
it('counts back over a month boundary', () => {
expect(withdrawalRangeText('2026-11-01', today)).toBe(
'bis einschließlich 31.10. – ab 01.11. wieder erntefähig',
);
});
it('adds the year when the first free day lies in another year than the last blocked day', () => {
expect(withdrawalRangeText('2027-01-01', today)).toBe(
'bis einschließlich 31.12.2026 – ab 01.01.2027 wieder erntefähig',
);
});
it('adds the year when the first free day lies in another year than today', () => {
expect(withdrawalRangeText('2027-01-10', today)).toBe(
'bis einschließlich 09.01.2027 – ab 10.01.2027 wieder erntefähig',
);
});
});
@@ -0,0 +1,30 @@
import { addDays, berlinToday } from '../common/dates';
import { formatDayDe } from '../common/format-day';
/**
* The window is [fromDay, untilDay): untilDay is the first free day, the day
* before it is the last blocked day (inclusive). The one place that subtracts
* that day; calendar and texts both read it.
*/
export function lastBlockedDay(untilDay: string): string {
return addDays(untilDay, -1);
}
/**
* The one wording of a withdrawal window's end: the last blocked day
* (inclusive) and the first free day. Every user-facing text (warnings,
* notifications, calendar, withdrawal status) builds on this; the days come
* from the window helper in treatments/, nothing is calculated twice. The
* year is added when the end is not in the current year or the window spans
* a year change.
*/
export function withdrawalRangeText(
untilDay: string,
today: string = berlinToday(),
): string {
const last = lastBlockedDay(untilDay);
const withYear =
untilDay.slice(0, 4) !== last.slice(0, 4) ||
untilDay.slice(0, 4) !== today.slice(0, 4);
return `bis einschließlich ${formatDayDe(last, withYear)} – ab ${formatDayDe(untilDay, withYear)} wieder erntefähig`;
}
@@ -39,6 +39,14 @@ const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
});
describe('WithdrawalEventHandlers', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let check: {
conflictsForHarvests: jest.Mock;
conflictsForHoneyBatch: jest.Mock;
@@ -193,7 +201,7 @@ describe('WithdrawalEventHandlers', () => {
priority: 'INFO',
category: 'WITHDRAWAL',
message:
'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.',
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
relatedType: 'Colony',
relatedId: 'c1',
link: '/colonies/c1',
@@ -5,12 +5,12 @@ import { isLivingColonyStatus } from '../colonies/colony-status';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { formatDayDe } from '../suggestions/texts/format-day';
import {
isInWindow,
withdrawalWindowOf,
} from '../treatments/withdrawal-window';
import { WithdrawalCheckService } from './withdrawal-check.service';
import { withdrawalRangeText } from './withdrawal-text';
const READY_RECIPIENT_PERMISSIONS = [
'harvests:create',
@@ -123,7 +123,7 @@ export class WithdrawalEventHandlers {
{
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`,
message: `${treatment.colony.code}: Wartezeit nach ${treatment.product} ${withdrawalRangeText(endedOn)}.`,
relatedType: 'Colony',
relatedId: treatment.colonyId,
link: `/colonies/${treatment.colonyId}`,
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -152,6 +153,9 @@ describe('Calendar (e2e)', () => {
);
expect(span).toMatchObject({
title: `Wartezeit ${colonyCode} · Oxuvar`,
context: colonyCode,
product: 'Oxuvar',
detail: withdrawalRangeText('2027-06-20'),
relatedType: 'Colony',
relatedId: colonyId,
});
@@ -16,6 +16,8 @@ interface TreatmentBody {
interface WithdrawalStatusBody {
colonyId: string;
activeUntil: string;
lastBlockedDay: string;
withdrawalText: string;
product: string;
}
@@ -254,6 +256,9 @@ describe('Treatments (e2e)', () => {
{
colonyId,
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Oxalsäure',
},
]);
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -264,7 +265,7 @@ describe('Withdrawal chain (e2e)', () => {
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
refs: [
{ type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatmentIds[0] },
@@ -283,7 +284,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
relatedType: 'Harvest',
relatedId: harvestId,
link: `/harvests/${harvestId}`,
@@ -332,7 +333,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(bottling.warnings).toHaveLength(1);
expect(bottling.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
});
await drainDomainEvents(app, [bottling.id]);
@@ -515,7 +516,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-06-25')}.`,
relatedType: 'Colony',
link: `/colonies/${colony.id}`,
});
@@ -558,7 +559,7 @@ describe('Withdrawal chain (e2e)', () => {
where: { userId: readerId, relatedId: colony.id },
});
expect(rows).toHaveLength(1);
expect(rows[0].message).toContain('endete am 03.06.2026');
expect(rows[0].message).toContain(withdrawalRangeText('2026-06-03'));
expect(rows[0].message).toContain(colony.code);
});
});
@@ -4,7 +4,8 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { fetchProducts } from "@/lib/products/api";
import { toast } from "sonner";
@@ -13,7 +14,11 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"),
}));
vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatches: vi.fn(),
fetchHoneyBatchTraceability: vi.fn(),
}));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
vi.mock("sonner", () => ({
@@ -39,6 +44,8 @@ describe("NewBottlingBatchPage", () => {
{ id: "hb-1", batchNumber: "2026-01", amount: 40 },
] as never);
vi.mocked(fetchProducts).mockResolvedValue([] as never);
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never);
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
vi.mocked(createBottlingBatch).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset();
@@ -100,7 +107,7 @@ describe("NewBottlingBatchPage", () => {
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [{ type: "Colony", id: "c-3" }],
},
],
@@ -114,10 +121,45 @@ describe("NewBottlingBatchPage", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Abfüllung angelegt · 1 Warnung",
expect.objectContaining({
description: expect.stringMatching(/B3.*20\.08\.2026/),
description: expect.stringMatching(/B3.*20.08./),
}),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/bottling/b-1");
});
it("shows the withdrawal of a colony behind the batch once, before saving", async () => {
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({
harvests: [
{ id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] },
{ id: "h-2", date: "2026-10-15T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }, { id: "c-3", code: "V-03" }] },
],
} as never);
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (ids) =>
ids.includes("c-2")
? [{ colonyId: "c-2", activeUntil: "2026-10-27T00:00:00.000Z", lastBlockedDay: "2026-10-26", withdrawalText: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig", product: "Oxuvar" }]
: [],
);
renderPage();
const hints = await screen.findAllByText(/V-02: Wartezeit nach Oxuvar bis einschließlich 26\.10\./);
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent("ab 27.10. wieder erntefähig");
expect(fetchWithdrawalStatus).toHaveBeenCalledWith(expect.arrayContaining(["c-2"]), "2026-10-14");
});
it("shows no hint when the batch has no harvest inside a window", async () => {
renderPage();
await screen.findByRole("button", { name: "Abfüllung anlegen" });
expect(screen.queryByText(/Wartezeit nach/)).not.toBeInTheDocument();
});
it("says so when the withdrawal check fails instead of showing nothing", async () => {
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({
harvests: [{ id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] }],
} as never);
vi.mocked(fetchWithdrawalStatus).mockRejectedValue(new Error("offline"));
renderPage();
expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument();
});
});
@@ -4,10 +4,11 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Package } from "lucide-react";
import { AlertTriangle, Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch } from "@/lib/bottling/api";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
@@ -88,6 +89,10 @@ export default function NewBottlingBatchPage() {
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
const { hints: withdrawalHints, failed: withdrawalCheckFailed } = useBottlingWithdrawalHints(
values.honeyBatchId,
);
function set<K extends keyof BottlingFormValues>(
key: K,
value: BottlingFormValues[K],
@@ -197,6 +202,27 @@ export default function NewBottlingBatchPage() {
/>
)}
</Field>
{withdrawalCheckFailed ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
Wartezeit konnte nicht geprüft werden
</p>
) : null}
{withdrawalHints.length > 0 ? (
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
<p className="flex items-center gap-2 font-medium">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
Honig aus einer Wartezeit
</p>
<ul className="flex flex-col gap-1">
{withdrawalHints.map((hint) => (
<li key={`${hint.colonyId}-${hint.product}-${hint.withdrawalText}`}>
{hint.colonyCode}: Wartezeit nach {hint.product} {hint.withdrawalText}.
</li>
))}
</ul>
</div>
) : null}
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
@@ -59,7 +59,7 @@ describe("CalendarPage withdrawal spans", () => {
const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0];
expect(link).toHaveAttribute("href", "/colonies/c-3");
const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Einträge/ });
const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Eintrag$/ });
await userEvent.click(day);
expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument();
});
@@ -102,3 +102,144 @@ describe("CalendarPage withdrawal spans", () => {
expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument();
});
});
describe("CalendarPage entries", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z"));
vi.mocked(fetchApiaries).mockResolvedValue([] as never);
});
afterEach(() => vi.useRealTimers());
it("names the colony or apiary on rows that do not carry it in their title", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "task:1",
type: "TASK",
date: "2026-08-12T00:00:00.000Z",
title: "Fütterung erforderlich: Futterstatus kritisch.",
relatedType: "Task",
relatedId: "t-1",
context: "V-07",
},
{
id: "h:1",
type: "HARVEST",
date: "2026-08-12T00:00:00.000Z",
title: "Ernte",
relatedType: "Apiary",
relatedId: "a-1",
context: "Teststand Nord",
},
{
id: "i:1",
type: "INSPECTION",
date: "2026-08-12T00:00:00.000Z",
title: "Durchsicht V-01",
relatedType: "Colony",
relatedId: "c-1",
context: "V-01",
},
] as never);
renderPage();
const phoneList = (await screen.findAllByText("Ernte", { selector: "span.truncate" }))[0].closest(
"div.sm\\:hidden",
) as HTMLElement;
expect(within(phoneList).getByText("V-07")).toBeInTheDocument();
expect(within(phoneList).getByText("Teststand Nord")).toBeInTheDocument();
// The title already says V-01: no second mention.
expect(within(phoneList).queryByText("V-01")).not.toBeInTheDocument();
});
it("gives month chips the full text as title and accessible name", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const day = await screen.findByRole("button", { name: /Dienstag, 11\. August/ });
const chip = within(day).getByText("Wartezeit B3 · Oxuvar").closest("span[title]") as HTMLElement;
expect(chip).toHaveAttribute(
"title",
"Wartezeit B3 · Oxuvar – Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
);
});
it("labels the end of a withdrawal span in its rows", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const link = (await screen.findAllByRole("link", { name: /Wartezeit B3/ }))[0];
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
});
it("counts a day with several entries in the plural", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
{ id: "b", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "B", relatedType: "Task", relatedId: "2" },
]);
renderPage();
expect(await screen.findByRole("button", { name: /Mittwoch, 12\. August.*2 Einträge/ })).toBeInTheDocument();
});
it("matches the colony code as a whole word (V-1 is not hidden by V-10)", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "i:1",
type: "INSPECTION",
date: "2026-08-12T00:00:00.000Z",
title: "Durchsicht V-10",
relatedType: "Colony",
relatedId: "c-1",
context: "V-1",
},
]);
renderPage();
const row = (await screen.findAllByRole("link", { name: /Durchsicht V-10/ }))[0];
expect(within(row).getByText("V-1", { selector: "span.shrink-0" })).toBeInTheDocument();
});
it("says the withdrawal span once on its first day and marks the following days compactly", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
context: "B3",
product: "Ameisensäure 60 %",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const rows = await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement;
expect(within(phoneList).getAllByText(/wieder erntefähig/)).toHaveLength(1);
expect(within(phoneList).getAllByText(/Wartezeit läuft weiter/)).toHaveLength(2);
// The product is on its own line, not cut off with the title.
expect(within(phoneList).getByText(/Ameisensäure 60 %/)).toBeInTheDocument();
expect(within(phoneList).getAllByRole("link", { name: /Wartezeit/ })).toHaveLength(3);
});
});
@@ -26,6 +26,7 @@ import { ErrorState, LoadingState } from "@/components/query-state";
import { RecordSelect } from "@/components/form/record-select";
import { cn } from "@/lib/utils";
import { toLocalIsoDate } from "@/lib/dates";
import { entryCountText } from "@/lib/text/plural";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -59,6 +60,36 @@ function eventDayKeys(event: CalendarEvent): string[] {
);
}
/** True when `word` stands in `text` as a whole token: "V-1" is not said by "V-10". */
function mentionsWord(text: string, word: string): boolean {
const isWordChar = (char: string | undefined) => char !== undefined && /[A-Za-z0-9_-]/.test(char);
let from = text.indexOf(word);
while (from !== -1) {
const end = from + word.length;
if (!isWordChar(text[from - 1]) && !isWordChar(text[end])) return true;
from = text.indexOf(word, from + 1);
}
return false;
}
/** Colony code or apiary name, unless the title already says it. */
function eventContext(event: CalendarEvent): string | null {
const context = event.context || null;
if (!context) return null;
return mentionsWord(event.title, context) ? null : context;
}
/** For a withdrawal span: until when honey is blocked and when it is free again. */
function eventNote(event: CalendarEvent): string | null {
if (event.type !== "WITHDRAWAL") return null;
return event.detail ? `Wartezeit ${event.detail}` : null;
}
/** Everything a row says, as one text - the tooltip of a truncated month chip. */
function eventFullText(event: CalendarEvent): string {
return [event.title, eventContext(event), eventNote(event)].filter(Boolean).join(" – ");
}
function eventHref(event: CalendarEvent): string | null {
if (event.relatedType === "Task") return "/tasks";
if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`;
@@ -82,16 +113,37 @@ function dayHeading(date: Date): string {
return date.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
}
function EventRow({ event }: { event: CalendarEvent }) {
function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuation: boolean }) {
const meta = EVENT_META[event.type];
const Icon = meta.icon;
const href = eventHref(event);
const content = (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm", meta.tone)}>
const context = eventContext(event);
const note = isContinuation ? null : eventNote(event);
const product = isContinuation ? null : (event.product ?? null);
const content = isContinuation ? (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-xs", meta.tone)}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 font-medium text-muted-foreground">{meta.label}</span>
<span className="truncate text-muted-foreground">
{[context, event.title].filter(Boolean).join(" · ")} – Wartezeit läuft weiter
</span>
</span>
) : (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 py-1 text-sm", meta.tone)}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{meta.label}</span>
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 items-baseline gap-2">
{context ? <span className="shrink-0 font-medium">{context}</span> : null}
<span className="truncate">{event.title}</span>
</span>
{product || note ? (
<span className="text-xs text-muted-foreground">
{[product, note].filter(Boolean).join(" – ")}
</span>
) : null}
</span>
</span>
);
return href ? (
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50">
@@ -106,10 +158,13 @@ function DayEvents({
date,
events,
isToday,
isContinuation = () => false,
}: {
date: Date;
events: CalendarEvent[];
isToday: boolean;
/** True for a withdrawal span that already showed in full on an earlier day of the list. */
isContinuation?: (event: CalendarEvent) => boolean;
}) {
return (
<section className="flex flex-col gap-2">
@@ -127,7 +182,7 @@ function DayEvents({
<ul className="flex flex-col gap-1.5">
{events.map((event) => (
<li key={event.id}>
<EventRow event={event} />
<EventRow event={event} isContinuation={isContinuation(event)} />
</li>
))}
</ul>
@@ -180,6 +235,14 @@ export default function CalendarPage() {
(day) =>
day.getMonth() === month.getMonth() && (eventsByDay.get(toLocalIsoDate(day))?.length ?? 0) > 0,
);
// A withdrawal span is told in full on the first listed day, then only marked.
const firstShownDay = new Map<string, string>();
for (const day of daysOfMonthWithEvents) {
const key = toLocalIsoDate(day);
for (const event of eventsByDay.get(key) ?? []) {
if (event.type === "WITHDRAWAL" && !firstShownDay.has(event.id)) firstShownDay.set(event.id, key);
}
}
const selectedDay = gridDays.find((day) => toLocalIsoDate(day) === selectedKey);
function goToMonth(offset: number) {
@@ -258,6 +321,9 @@ export default function CalendarPage() {
date={day}
events={eventsByDay.get(toLocalIsoDate(day)) ?? []}
isToday={toLocalIsoDate(day) === todayKey}
isContinuation={(event) =>
event.type === "WITHDRAWAL" && firstShownDay.get(event.id) !== toLocalIsoDate(day)
}
/>
))
)}
@@ -286,7 +352,7 @@ export default function CalendarPage() {
type="button"
onClick={() => setSelectedKey(key)}
aria-pressed={isSelected}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${dayEvents.length} Einträge`}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
isCurrentMonth ? "bg-card" : "bg-muted/40",
@@ -317,13 +383,16 @@ export default function CalendarPage() {
return (
<span
key={event.id}
title={eventFullText(event)}
className={cn(
"flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs",
meta.tone,
)}
>
<Icon className="size-3 shrink-0" aria-hidden />
<span className="truncate">{event.title}</span>
<span className="truncate">
{[eventContext(event), event.title].filter(Boolean).join(" · ")}
</span>
</span>
);
})}
@@ -121,6 +121,7 @@ describe("ColonyActions – colonies that need attention", () => {
for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) {
expect(screen.getByRole("button", { name })).toBeEnabled();
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
}
expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument();
});
@@ -95,7 +95,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Umsetzen
</Button>
}
@@ -216,7 +216,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Ableger bilden
</Button>
}
@@ -325,7 +325,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
{currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"}
</Button>
}
@@ -398,7 +398,7 @@ function MergeDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Vereinigen
</Button>
}
@@ -461,6 +461,7 @@ function DissolveButton({ colony }: { colony: Colony }) {
return (
<ConfirmDialog
triggerLabel="Auflösen"
triggerClassName="min-h-11"
triggerDisabled={!isLivingColony(colony) || mutation.isPending}
title="Volk auflösen?"
description={`„${colony.code}“ wird als aufgelöst markiert und aus den aktiven Völkern entfernt. Die Historie bleibt erhalten und das Volk kann jederzeit wieder reaktiviert werden.`}
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() }));
@@ -228,4 +229,19 @@ describe("ColonyEditCard – legacy strength after clearing", () => {
expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength");
});
it("shows the cached apiary name read-only while the apiary list cannot be loaded", async () => {
vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("offline"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(["apiaries", "apiary-1"], { id: "apiary-1", name: "Obstwiese" });
render(
<QueryClientProvider client={client}>
<ColonyEditCard colony={colony} />
</QueryClientProvider>,
);
expect(await screen.findByText("Obstwiese")).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: /Bienenstand/ })).not.toBeInTheDocument();
expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument();
});
});
@@ -89,10 +89,18 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
);
const [notes, setNotes] = useState(colony.notes ?? "");
const { data: apiaries } = useQuery({
const apiariesQuery = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const apiaries = apiariesQuery.data;
// Without the list (offline, or it failed) the select would sit on "Wird
// geladen…" for good: show the apiary by its cached name instead.
const apiaryListUnavailable = !apiaries && apiariesQuery.fetchStatus !== "fetching";
const cachedApiaryName =
apiaries?.find((apiary) => apiary.id === colony.apiaryId)?.name ??
queryClient.getQueryData<{ name: string }>(["apiaries", colony.apiaryId])?.name ??
null;
const updateMutation = useMutation({
mutationFn: () =>
@@ -136,6 +144,11 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-apiary">Bienenstand</Label>
{apiaryListUnavailable ? (
<p id="colony-apiary" className="min-h-11 content-center text-sm">
{cachedApiaryName ?? "Bienenstand derzeit nicht ladbar"}
</p>
) : (
<RecordSelect
id="colony-apiary"
value={apiaryId}
@@ -145,6 +158,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
)}
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
festgehalten.
@@ -57,7 +57,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
{(feedings) =>
feedings.map((feeding) => (
<div key={feeding.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
{FEED_TYPE_LABELS[feeding.feedType]}
@@ -44,7 +44,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{(inspections) =>
inspections.map((inspection) => (
<div key={inspection.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(inspection.date).toLocaleDateString("de-DE")}
</p>
@@ -46,7 +46,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
{(checks) =>
checks.map((check) => (
<div key={check.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
<SwarmControlEditDialog check={check} />
</div>
@@ -44,7 +44,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{(treatments) =>
treatments.map((treatment) => (
<div key={treatment.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p>
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
@@ -188,4 +188,33 @@ describe("ColonyVarroaCard trend", () => {
expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument();
});
it("lets a row's heading and edit button wrap instead of overflowing the card", async () => {
renderCard();
await screen.findByRole("img");
const row = screen.getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /)[0].parentElement;
expect(row).toHaveClass("flex-wrap");
});
it("lists the measurements newest first", async () => {
renderCard();
await screen.findByRole("img");
const dates = screen
.getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /)
.map((node) => node.textContent?.split(" · ")[0]);
expect(dates).toEqual(["15.6.2026", "8.6.2026", "1.6.2026"]);
});
it("labels the date axis and explains the lines in a legend", async () => {
renderCard();
const chart = await screen.findByRole("img");
const axis = within(chart as unknown as HTMLElement);
expect(axis.getByText("01.06.2026")).toBeInTheDocument();
expect(axis.getByText("15.06.2026")).toBeInTheDocument();
await screen.findByText("Rote Grenze");
const legend = screen.getByRole("list", { name: "Legende" });
expect(within(legend).getByText("Milbenfall pro Tag")).toBeInTheDocument();
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument();
});
});
@@ -18,12 +18,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 300;
const CHART_HEIGHT = 120;
const CHART_HEIGHT = 136;
const PAD_X = 12;
const PAD_TOP = 12;
const PAD_BOTTOM = 12;
const PAD_BOTTOM = 28;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
const AXIS_LABEL_SIZE = 10;
const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -74,6 +75,13 @@ function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
.sort((left, right) => left.time - right.time);
}
/** Newest first, like the inspections; on the same day the later entry comes first. */
function newestFirst(measurements: VarroaMeasurement[]): VarroaMeasurement[] {
return [...measurements].sort(
(left, right) => right.date.localeCompare(left.date) || right.createdAt.localeCompare(left.createdAt),
);
}
function nextMonthStart(start: number): number {
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
@@ -194,7 +202,11 @@ function VarroaTrendChart({
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
const monthly = monthLimitsText(limits, range);
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
const labelY = CHART_HEIGHT - LABEL_GAP;
return (
<div className="flex flex-col gap-2">
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary"
@@ -229,6 +241,27 @@ function VarroaTrendChart({
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
) : null}
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={axisY}
y2={axisY}
stroke="currentColor"
strokeWidth={0.5}
className="text-muted-foreground"
/>
<text x={PAD_X} y={labelY} fontSize={AXIS_LABEL_SIZE} className="fill-muted-foreground">
{formatDate(first.time)}
</text>
<text
x={CHART_WIDTH - PAD_X}
y={labelY}
textAnchor="end"
fontSize={AXIS_LABEL_SIZE}
className="fill-muted-foreground"
>
{formatDate(last.time)}
</text>
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
@@ -242,6 +275,42 @@ function VarroaTrendChart({
/>
))}
</svg>
<ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} />
</div>
);
}
function LegendSwatch({ dash, width }: { dash?: string; width: number }) {
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="shrink-0">
<line x1="0" x2="24" y1="4" y2="4" stroke="currentColor" strokeWidth={width} strokeDasharray={dash} />
</svg>
);
}
/** Says what each line of the chart is - the limit lines are dashed and not obvious. */
function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolean }) {
return (
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<li className="flex items-center gap-1.5">
<span className="text-primary">
<LegendSwatch width={2} />
</span>
<span>Milbenfall pro Tag</span>
</li>
{hasRed ? (
<li className="flex items-center gap-1.5">
<LegendSwatch width={1.5} dash="6 4" />
<span>Rote Grenze</span>
</li>
) : null}
{hasYellow ? (
<li className="flex items-center gap-1.5">
<LegendSwatch width={1} dash="2 3" />
<span>Gelbe Grenze</span>
</li>
) : null}
</ul>
);
}
@@ -296,9 +365,9 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
{(measurements) => (
<>
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
{measurements.map((measurement) => (
{newestFirst(measurements).map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
{VARROA_METHOD_LABELS[measurement.method]}
@@ -39,10 +39,33 @@ describe("EditTriggerButton", () => {
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten");
});
it("wraps its text on a narrow screen instead of overflowing", () => {
it("names the reason in visible text next to the offline button", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal");
expect(screen.getByText("Nur mit Verbindung")).toBeVisible();
});
it("is at least 44 px high", () => {
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("min-h-11");
});
it("keeps its label on one line so it never crowds the heading next to it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("shrink-0", "whitespace-nowrap");
expect(screen.getByRole("button")).not.toHaveClass("whitespace-normal");
});
it("lets the offline button and its hint wrap inside the card instead of overflowing it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
const wrapper = screen.getByText("Nur mit Verbindung").parentElement;
expect(wrapper).toHaveClass("ml-auto", "max-w-full", "flex-wrap");
expect(wrapper).not.toHaveClass("shrink-0");
});
});
@@ -1,7 +1,7 @@
"use client";
import { ComponentProps } from "react";
import { Pencil } from "lucide-react";
import { Pencil, WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSessionMode } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
@@ -11,7 +11,10 @@ import { cn } from "@/lib/utils";
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
* a phone, so it can be hit with a gloved thumb. Saved records can only be
* changed with a connection (edits are not queued), so offline - or in an
* offline session without a token - it is disabled and says why.
* offline session without a token - it is disabled and says why: a "no
* network" icon and a tooltip for the eye, the full sentence for screen
* readers. The visible label stays one short word so it never pushes the
* heading of its row out of the way on a phone.
*/
export function EditTriggerButton({
disabled,
@@ -21,17 +24,30 @@ export function EditTriggerButton({
const connectivity = useConnectivity();
const sessionMode = useSessionMode();
const needsNetwork = connectivity === "offline" || sessionMode === "offline";
return (
const button = (
<Button
variant="ghost"
{...props}
disabled={disabled || needsNetwork}
className={cn(needsNetwork && "h-auto whitespace-normal text-left", className)}
title={needsNetwork ? "Bearbeiten braucht Netz" : props.title}
aria-label={needsNetwork ? "Bearbeiten braucht Netz" : props["aria-label"]}
className={cn("min-h-11", needsNetwork && "shrink-0 whitespace-nowrap", className)}
>
{needsNetwork ? (
<WifiOff className="size-4" aria-hidden />
) : (
<Pencil className="size-4" aria-hidden />
{needsNetwork ? "Bearbeiten braucht Netz" : "Bearbeiten"}
)}
Bearbeiten
</Button>
);
if (!needsNetwork) return button;
return (
<span className="ml-auto inline-flex max-w-full flex-wrap items-center justify-end gap-x-2">
{button}
<span className="text-xs text-muted-foreground">Nur mit Verbindung</span>
</span>
);
}
interface DialogSubmitBarProps {
@@ -1,12 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import DashboardPage from "./page";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { fetchTasks } from "@/lib/tasks/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Task } from "@/lib/tasks/types";
vi.mock("@/lib/auth/auth-context", () => ({
@@ -26,6 +28,7 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
@@ -61,6 +64,10 @@ function renderPage() {
}
describe("DashboardPage – Heute", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
apiariesActive: 1,
@@ -172,4 +179,77 @@ describe("DashboardPage – Heute", () => {
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: "Runde starten" })).not.toHaveClass("bg-primary");
});
it("offline: shows the „Stand“ of the cached data and switches off the decisions of the next step", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
nextStep: {
...stepFixture({
title: "Honigraum",
intent: "HONEY_SUPER",
action: { type: "ACCEPT", suggestionId: "s1" },
}),
colonyId: "c1",
colonyCode: "A1",
},
}),
);
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
expect(screen.getAllByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toHaveLength(1);
});
it("online: shows no „Stand“", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
await screen.findByRole("heading", { name: "Überblick" });
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
});
it("offline: task checkboxes and „Erledigt“/„Beginnen“ are off with „Nur mit Verbindung“ (task changes are not queued)", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(fetchTasks).mockResolvedValue([
overdueTask(),
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
]);
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByRole("button", { name: "Erledigt" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Beginnen" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /Zweite Aufgabe/ })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
});
it("gives the dashboard text links a 44 px target", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
expect(await screen.findByRole("link", { name: "Alle Aufgaben" })).toHaveClass("min-h-11");
});
it("names the colony in „Außerdem heute“ rows from the colonies list", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
vi.mocked(fetchColonies).mockResolvedValue([{ id: "col-7", code: "V-07" }] as never);
vi.mocked(fetchTasks).mockResolvedValue([
overdueTask(),
{
...overdueTask(),
id: "t3",
title: "Fütterung erforderlich",
relatedType: "COLONY",
relatedId: "col-7",
},
]);
renderPage();
const row = (await screen.findByText("Fütterung erforderlich")).closest("li") as HTMLElement;
expect(await within(row).findByText("V-07")).toBeInTheDocument();
expect(fetchColonies).toHaveBeenCalledTimes(1);
});
});
@@ -25,6 +25,7 @@ import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null {
@@ -89,14 +90,16 @@ const ALERT_META: Record<
export default function DashboardPage() {
const { user } = useAuth();
const [now] = useState(() => new Date());
const { data: summary } = useQuery({
const summaryQuery = useQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
});
const { data: alerts, isLoading: alertsLoading } = useQuery({
const { data: summary } = summaryQuery;
const alertsQuery = useQuery({
queryKey: ["dashboard", "alerts"],
queryFn: fetchDashboardAlerts,
});
const { data: alerts, isLoading: alertsLoading } = alertsQuery;
const tasksQuery = useQuery({
queryKey: ["tasks", "dashboard"],
queryFn: () => fetchTasks({}),
@@ -119,6 +122,14 @@ export default function DashboardPage() {
(autopilotError instanceof ApiError && autopilotError.status === 403));
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
// Offline everything shown is from the cache: the oldest part sets the „Stand“.
const loadedAt = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery]
.map((query) => query.dataUpdatedAt)
.filter((updatedAt) => updatedAt > 0);
const hasStaleData = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery].some(
(query) => query.isError && query.data !== undefined,
);
const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0;
const firstName = user?.name.trim().split(/\s+/)[0];
const tiles: StatTile[] = summary
@@ -161,6 +172,7 @@ export default function DashboardPage() {
</p>
</div>
</header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
{autopilotError && !isAutopilotSilent ? (
<div
@@ -211,7 +223,7 @@ export default function DashboardPage() {
</span>
{href ? (
<Link
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary underline-offset-4 hover:underline"
href={href}
>
Ansehen
@@ -100,7 +100,7 @@ describe("NewHarvestPage", () => {
it("checks the withdrawal period at the harvest date and again when it changes", async () => {
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) =>
at === "2026-07-01"
? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", product: "Oxuvar" }]
? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", lastBlockedDay: "2026-07-09", withdrawalText: "bis einschließlich 09.07. – ab 10.07. wieder erntefähig", product: "Oxuvar" }]
: [],
);
renderPage();
@@ -114,7 +114,7 @@ describe("NewHarvestPage", () => {
await userEvent.clear(date);
await userEvent.type(date, "2026-07-01");
expect(await screen.findByText(/Wartezeit \(Oxuvar\) bis 10\.7\.2026/)).toBeInTheDocument();
expect(await screen.findByText(/Wartezeit nach Oxuvar bis einschließlich 09\.07\. – ab 10\.07\. wieder erntefähig/)).toBeInTheDocument();
expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith(
["colony-1", "colony-2"],
"2026-07-01",
@@ -153,7 +153,7 @@ describe("NewHarvestPage", () => {
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "V-01: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "V-01: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [],
},
],
@@ -165,7 +165,7 @@ describe("NewHarvestPage", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Ernte gespeichert · 1 Warnung",
expect.objectContaining({ description: expect.stringContaining("20.08.2026") }),
expect.objectContaining({ description: expect.stringContaining("20.08.") }),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/harvests/h-1");
@@ -270,10 +270,8 @@ export default function NewHarvestPage() {
className="size-3.5 shrink-0"
aria-hidden
/>
Wartezeit ({withdrawal.product}) bis{" "}
{new Date(
withdrawal.activeUntil,
).toLocaleDateString("de-DE")}
Wartezeit nach {withdrawal.product}{" "}
{withdrawal.withdrawalText}
</p>
) : null}
</div>
+46 -2
View File
@@ -7,12 +7,14 @@ import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
const pushMock = vi.fn();
let pathname = "/inspections/new";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => "/inspections/new",
usePathname: () => pathname,
}));
let sessionMode: "online" | "offline" = "online";
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
@@ -22,7 +24,7 @@ vi.mock("@/lib/auth/auth-context", () => ({
name: "Test Imker",
roleKey: "admin",
roleName: "Admin",
permissions: [],
permissions,
mfaEnabled: false,
hasAvatar: false,
},
@@ -91,9 +93,51 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
describe("AppLayout", () => {
beforeEach(() => {
sessionMode = "online";
pathname = "/inspections/new";
permissions = [];
pushMock.mockClear();
});
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
pathname = "/suggestions";
permissions = ["inspections:create"];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const content = screen.getByText("Seiteninhalt").closest("[tabindex='-1']") as HTMLElement;
// FAB top edge = nav + 1rem gap + 3rem button = nav + 4rem; padding nav + 4.5rem leaves 0.5rem free
// below the last button of the page, so the FAB never covers it at the end of the scroll.
expect(content.className).toContain("pb-[calc(var(--bottom-nav-height)+--spacing(18))]");
expect(18 * 0.25).toBeGreaterThan(1 + 3);
});
it("adds no extra room on a form page (the quick-add button is hidden there) or without the button", () => {
permissions = ["inspections:create"];
const { unmount } = render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
unmount();
pathname = "/suggestions";
permissions = [];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
});
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
+10 -3
View File
@@ -2,7 +2,7 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
@@ -21,11 +21,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { QuickAddSheet } from "./quick-add-sheet";
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
// Focus lands here when the offline queue sheet closes and nothing else is left.
@@ -57,6 +58,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return null;
}
// The phone's quick-add button floats 16 px above the bottom nav and is
// 48 px high: without room under the page it would cover the last buttons.
const contentPaddingClass = hasQuickAddButton(pathname, user.permissions)
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
: "pb-(--bottom-nav-height)";
// The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
@@ -112,7 +119,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div
ref={pageContentRef}
tabIndex={-1}
className="flex-1 pb-(--bottom-nav-height) outline-none sm:pb-(--safe-area-bottom)"
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
>
<PageTransition>{children}</PageTransition>
</div>
@@ -18,6 +18,14 @@ import { QUICK_ACTIONS } from "./quick-actions";
// quick action anyway - and the header button hides with it for consistency.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
/** Whether the floating/header button is there at all on this route for this user. */
export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean {
return (
!FORM_ROUTE.test(pathname) &&
QUICK_ACTIONS.some((action) => permissions.includes(action.permission))
);
}
interface QuickAddSheetProps {
user: Pick<AuthUser, "permissions">;
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */
@@ -32,7 +40,7 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
user.permissions.includes(action.permission),
);
if (FORM_ROUTE.test(pathname) || actions.length === 0) {
if (!hasQuickAddButton(pathname, user.permissions)) {
return null;
}
@@ -48,9 +48,13 @@ function renderTab(rules: AutomationRule[]) {
);
}
/** The rule cards start collapsed: this finds one and opens it. */
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
const element = heading.closest("[data-slot='card']") as HTMLElement;
const toggle = within(element).getByRole("button", { expanded: false });
await userEvent.click(toggle);
return within(element);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
@@ -277,3 +281,43 @@ describe("other rule forms", () => {
expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 });
});
});
describe("collapsible rule cards", () => {
it("starts every card collapsed with a summary line and opens one at a time", async () => {
const user = userEvent.setup();
renderTab([
rule("FEED_STATUS_CRITICAL", { notify: true, dueInDays: 2 }),
rule("WITHDRAWAL_ENDED", { notify: true }),
]);
const first = (await screen.findByText("Futterstatus kritisch")).closest(
"[data-slot='card']",
) as HTMLElement;
const second = (await screen.findByText("Wartezeit zu Ende")).closest("[data-slot='card']") as HTMLElement;
expect(within(first).getAllByRole("button", { expanded: false })).toHaveLength(1);
expect(within(first).queryByLabelText("Aufgabentext")).not.toBeVisible();
expect(within(first).getByText(/^Frist 2 Tage · meldet zusätzlich$/)).toBeInTheDocument();
await user.click(within(first).getByRole("button", { expanded: false }));
expect(within(first).getByLabelText("Aufgabentext")).toBeVisible();
await user.click(within(second).getByRole("button", { expanded: false }));
expect(within(second).getByRole("checkbox", { name: "Zusätzlich melden" })).toBeVisible();
// Opening the second one closes the first.
expect(within(first).getByRole("button", { expanded: false })).toBeInTheDocument();
});
it("edits the task text in a multi-line field and explains „Zusätzlich melden“", async () => {
renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]);
const view = await card("Futterstatus kritisch");
expect(view.getByLabelText("Aufgabentext").tagName).toBe("TEXTAREA");
expect(view.getByText(/Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen/)).toBeInTheDocument();
});
it("makes Deaktivieren and Speichern at least 44 px high", async () => {
renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]);
const view = await card("Futterstatus kritisch");
expect(view.getByRole("button", { name: "Deaktivieren" })).toHaveClass("min-h-11");
expect(view.getByRole("button", { name: "Speichern" })).toHaveClass("min-h-11");
});
});
@@ -10,7 +10,6 @@ import {
} from "@/lib/automation/api";
import {
RULE_DESCRIPTIONS,
RULE_LABELS,
SIMPLE_RULE_NOTIFY_DEFAULTS,
issuesByField,
} from "@/lib/automation/rule-configs";
@@ -23,12 +22,12 @@ import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { RuleCardShell } from "./rule-card-shell";
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
@@ -95,7 +94,15 @@ function ruleNotify(rule: AutomationRule): boolean {
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
}
function RuleCard({ rule }: { rule: AutomationRule }) {
function RuleCard({
rule,
open,
onToggle,
}: {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
}) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [notify, setNotify] = useState(ruleNotify(rule));
@@ -162,14 +169,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
{numberFormatRule ? (
<div className="flex flex-col gap-2">
@@ -227,8 +227,9 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
) : (
<div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input
<Textarea
id={`template-${rule.id}`}
rows={3}
value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)}
/>
@@ -278,23 +279,32 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</p>
))}
{!numberFormatRule ? (
<ToggleChip label="Zusätzlich melden" checked={notify}
<div className="flex flex-col gap-1">
<ToggleChip
label="Zusätzlich melden"
checked={notify}
describedBy={`notify-hint-${rule.id}`}
onChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
/>
<p id={`notify-hint-${rule.id}`} className="text-xs text-muted-foreground">
Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen.
</p>
</div>
) : null}
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ active: !rule.active })}
>
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button type="button" disabled={mutation.isPending} onClick={handleSave}>
<Button type="button" className="min-h-11" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</div>
@@ -310,12 +320,12 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
onConfirm={() => resetMutation.mutate()}
/>
) : null}
</CardContent>
</Card>
</RuleCardShell>
);
}
export function AutomationRulesTab() {
const [openRuleId, setOpenRuleId] = useState<string | null>(null);
const { data: rules, isLoading, isError } = useQuery({
queryKey: ["automation-rules"],
queryFn: fetchAutomationRules,
@@ -332,11 +342,15 @@ export function AutomationRulesTab() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) =>
hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} />
{
const open = openRuleId === rule.id;
const onToggle = () => setOpenRuleId(open ? null : rule.id);
return hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
) : (
<RuleCard key={rule.id} rule={rule} />
),
<RuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
);
},
)}
</div>
</div>
@@ -0,0 +1,53 @@
"use client";
import { ReactNode, useId } from "react";
import { ChevronDown } from "lucide-react";
import { RULE_LABELS, ruleSummary } from "@/lib/automation/rule-configs";
import { AutomationRule } from "@/lib/automation/types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface RuleCardShellProps {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
children: ReactNode;
}
/**
* A rule as a collapsible card: the header (title, state, one summary line)
* is always there, the settings open on demand. Closed content stays in the
* page (hidden), so unsaved edits survive closing and opening again.
*/
export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellProps) {
const contentId = useId();
return (
<Card>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2">
<span className="font-heading text-base font-medium">{RULE_LABELS[rule.triggerType]}</span>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
</span>
<ChevronDown
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
<CardContent id={contentId} hidden={!open} className="flex flex-col gap-4 text-sm">
{children}
</CardContent>
</Card>
);
}
@@ -48,7 +48,9 @@ function renderTab(rules: AutomationRule[]) {
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
const element = heading.closest("[data-slot='card']") as HTMLElement;
await userEvent.click(within(element).getByRole("button", { expanded: false }));
return within(element);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
@@ -274,7 +276,7 @@ describe("a refreshed rule", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
<SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider>
);
const first = rule("PHENOLOGY", null);
@@ -299,7 +301,7 @@ describe("fix round 2", () => {
vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
<SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider>
);
const view = render(wrap(phenology));
@@ -11,7 +11,7 @@ import { AutomationTriggerType } from "@/lib/automation/types";
import { RuleDraft } from "./use-rule-draft";
export const NOTIFY_LABEL = "Zusätzlich melden";
export const NOTIFY_HINT = "Du bekommst dann zusätzlich eine Meldung.";
export const NOTIFY_HINT = "Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen.";
/** The switch every rule with a notification has. */
export function NotifyToggle({
@@ -166,7 +166,7 @@ export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormSh
</ul>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? "Speichert…" : "Speichern"}
</Button>
</div>
@@ -4,12 +4,11 @@ import { ComponentType, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateAutomationRule } from "@/lib/automation/api";
import { RULE_DESCRIPTIONS, RULE_LABELS, issuesByField } from "@/lib/automation/rule-configs";
import { RULE_DESCRIPTIONS, issuesByField } from "@/lib/automation/rule-configs";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RuleCardShell } from "./rule-card-shell";
import { ChainForm } from "./rule-forms/chain-form";
import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form";
import { NotifyForm } from "./rule-forms/notify-form";
@@ -44,7 +43,15 @@ export function hasRuleForm(trigger: AutomationTriggerType): boolean {
return trigger in RULE_FORMS;
}
export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
export function SpecialRuleCard({
rule,
open,
onToggle,
}: {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
}) {
const queryClient = useQueryClient();
const [issues, setIssues] = useState<Record<string, string>>({});
const Form = RULE_FORMS[rule.triggerType] as ComponentType<RuleFormProps>;
@@ -67,14 +74,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
<Form
key={JSON.stringify(rule.config)}
@@ -87,6 +87,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={mutation.isPending}
onClick={() => {
setIssues({});
@@ -96,7 +97,6 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
</div>
</CardContent>
</Card>
</RuleCardShell>
);
}
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
import { Toaster } from "@/components/ui/sonner";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import {
acceptSuggestion,
bulkAcceptSuggestions,
@@ -116,6 +117,10 @@ Object.defineProperty(window, "matchMedia", {
HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined;
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.clearAllMocks();
toast.dismiss();
@@ -478,4 +483,49 @@ describe("SuggestionsPage", () => {
expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument();
});
it("offline: marks the list with its „Stand“ and switches off every decision", async () => {
const dismissed = suggestionFixture({ id: "d1", status: "DISMISSED", title: "Varroa messen" });
mockLists(OPEN, { dismissed: [dismissed] });
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Alle übernehmen" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Verworfene (30 Tage)" }));
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
expect(within(list).getByRole("button", { name: "Wieder öffnen" })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
});
it("the „Verworfene“ chevron moves only where motion is allowed", async () => {
mockLists(OPEN);
renderPage();
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
const chevron = toggle.querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
it("online: a failed reload with cached data shows the „Stand“ of that data", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockLists(OPEN);
render(
<QueryClientProvider client={client}>
<SuggestionsPage />
</QueryClientProvider>,
);
await screen.findByText("3 Vorschläge");
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
vi.mocked(fetchSuggestions).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => {
await client.refetchQueries({ queryKey: ["suggestions"] });
});
expect(await screen.findByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
expect(screen.getByText("3 Vorschläge")).toBeInTheDocument();
});
});
@@ -8,10 +8,13 @@ import { toast } from "sonner";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { OnlineOnlyHint, StandNote } from "@/components/offline/stand-note";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { SuggestionGroup } from "@/components/suggestions/suggestion-group";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { startRound } from "@/lib/rounds/round-state";
import {
@@ -35,8 +38,6 @@ const UNDO_TOAST_MS = 8000;
/** The API takes 1 to 50 ids per bulk call. */
const BULK_LIMIT = 50;
const TASKS_HREF = "/tasks";
/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */
const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!";
/**
* What has to get focus once the list shows the result of a decision.
@@ -196,10 +197,7 @@ export default function SuggestionsPage() {
onSuccess: (_result, { suggestion }) => {
requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id });
refresh();
toast.success("Aufgabe angelegt", {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
toast.success("Aufgabe angelegt", { action: viewTasks });
},
onMutate: ({ suggestion }) => markPending([suggestion.id]),
onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]),
@@ -227,10 +225,7 @@ export default function SuggestionsPage() {
onSuccess: (count) => {
requestFocus({ target: "list", gone: null });
refresh();
toast.success(taskCountText(count), {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
toast.success(taskCountText(count), { action: viewTasks });
},
onMutate: (ids) => markPending(ids),
onSettled: (_result, _error, ids) => unmarkPending(ids),
@@ -258,7 +253,6 @@ export default function SuggestionsPage() {
toast("Verworfen", {
duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) },
classNames: { actionButton: TOAST_ACTION_CLASS },
});
},
onMutate: (suggestion) => markPending([suggestion.id]),
@@ -293,6 +287,11 @@ export default function SuggestionsPage() {
>
{countText(loaded, total)}
</p>
<StandNote
updatedAt={openQuery.dataUpdatedAt}
isStale={openQuery.isError && openQuery.data !== undefined}
className="-mt-3"
/>
<QueryState
query={{
@@ -349,7 +348,7 @@ export default function SuggestionsPage() {
>
<ChevronDown
aria-hidden
className={showDismissed ? "rotate-180 transition-transform" : "transition-transform"}
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
/>
Verworfene (30 Tage)
</Button>
@@ -403,6 +402,7 @@ function DismissedRow({
canReopen: boolean;
onReopen: () => void;
}) {
const isOffline = useConnectivity() === "offline";
const Icon = INTENT_ICONS[suggestion.intent];
const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · ");
return (
@@ -417,10 +417,13 @@ function DismissedRow({
</p>
</div>
{canReopen ? (
<Button size="touch" variant="outline" disabled={busy} onClick={onReopen}>
<div className="flex flex-col items-start gap-1">
<Button size="touch" variant="outline" disabled={busy || isOffline} onClick={onReopen}>
<RotateCcw aria-hidden />
Wieder öffnen
</Button>
<OnlineOnlyHint />
</div>
) : null}
</li>
);
+9
View File
@@ -197,6 +197,15 @@
--header-clearance: 4.5rem;
/* StickySubmit with its 48px button (~4.6rem) plus breathing room. */
--sticky-submit-clearance: 5.5rem;
/*
Toasts sit above the sticky save bar. Below sm also above the bottom nav,
which carries the quick-add button (nav + 1rem + 3rem, less than the bar).
*/
--toast-offset-bottom: calc(var(--bottom-nav-height) + var(--sticky-submit-clearance) + 0.5rem);
@variant sm {
--toast-offset-bottom: calc(var(--safe-area-bottom) + var(--sticky-submit-clearance) + 0.5rem);
}
}
@layer base {
+1 -1
View File
@@ -43,7 +43,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
<Toaster position="top-right" richColors />
<Toaster richColors />
<ServiceWorkerRegistration />
</body>
</html>
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(__dirname, "globals.css"), "utf-8");
describe("toast offset", () => {
it("clears bottom nav and save bar below sm, and only the save bar from sm up", () => {
expect(css).toMatch(
/--toast-offset-bottom:\s*calc\(var\(--bottom-nav-height\) \+ var\(--sticky-submit-clearance\) \+ 0\.5rem\)/,
);
expect(css).toMatch(
/@variant sm \{\s*--toast-offset-bottom:\s*calc\(var\(--safe-area-bottom\) \+ var\(--sticky-submit-clearance\) \+ 0\.5rem\)/,
);
});
});
@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NextStepsCard } from "./next-steps-card";
import { fetchColonyNextSteps } from "@/lib/autopilot/api";
import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api";
import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures";
import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { toast } from "sonner";
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
@@ -49,6 +50,10 @@ const TASK_STEP = stepFixture({
});
describe("NextStepsCard", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.mocked(fetchColonyNextSteps).mockReset();
vi.mocked(acceptSuggestion).mockReset();
@@ -233,4 +238,47 @@ describe("NextStepsCard", () => {
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement());
});
it("offline: decision buttons are off with „Nur mit Verbindung“ and the data says since when", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [
stepFixture({
intent: "HONEY_SUPER",
title: "Honigraum",
action: { type: "ACCEPT", suggestionId: "s1" },
}),
],
}),
);
act(() => reportRequestOutcome("network-error"));
renderCard();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveAttribute(
"aria-describedby",
screen.getByText("Nur mit Verbindung").id,
);
expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
});
it("keeps showing the cached steps when the reload fails at network level", async () => {
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(
colonyNextStepsKey("c1"),
colonyStepsFixture({ steps: [stepFixture({ title: "Aus dem Cache" })] }),
);
render(
<QueryClientProvider client={client}>
<NextStepsCard colonyId="c1" />
</QueryClientProvider>,
);
await waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled());
expect(await screen.findByText("Aus dem Cache")).toBeInTheDocument();
// Online, but the reload failed: the cached steps say since when.
expect(await screen.findByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react";
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { DueChip } from "@/components/task-status";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -51,6 +52,11 @@ export function StepActions({
}: StepActionsProps) {
const { action } = step;
const variant = primary ? "default" : "outline";
// Deciding needs the server; captures and links work offline.
const { isOffline, hintId, describedBy } = useOnlineOnly();
const hasAccept = action.type === "ACCEPT" && Boolean(onAccept) && canAccept;
const hasCleanup = action.type === "CLEANUP" && Boolean(onCleanup) && canDismiss;
const hasDismiss = step.dismissible && Boolean(onDismiss) && canDismiss;
return (
<div className="flex flex-wrap items-center gap-2">
{action.type === "CAPTURE" ? (
@@ -66,23 +72,42 @@ export function StepActions({
</Link>
) : null}
{action.type === "ACCEPT" && onAccept && canAccept ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onAccept(step)}>
<Button
size="touch"
variant={variant}
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onAccept(step)}
>
<Check aria-hidden />
Übernehmen
</Button>
) : null}
{action.type === "CLEANUP" && onCleanup && canDismiss ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onCleanup(step)}>
<Button
size="touch"
variant={variant}
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onCleanup(step)}
>
<Eraser aria-hidden />
Aufräumen
</Button>
) : null}
{step.dismissible && onDismiss && canDismiss ? (
<Button size="touch" variant="ghost" disabled={busy} onClick={() => onDismiss(step)}>
<Button
size="touch"
variant="ghost"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onDismiss(step)}
>
<X aria-hidden />
Verwerfen
</Button>
) : null}
{hasAccept || hasCleanup || hasDismiss ? <OnlineOnlyHint id={hintId} className="basis-full" /> : null}
</div>
);
}
@@ -180,7 +205,8 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
});
const stepActions = useStepActions(NEXT_STEPS_HEADING_ID);
if (query.isError) {
// A failed reload keeps what the cache has: the steps stay, marked with their „Stand“.
if (query.isError && !query.data) {
// Offline the colony page still works; a missing hint is no news.
if (isNetworkError(query.error)) return null;
return (
@@ -202,6 +228,7 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
return (
<>
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError} />
<NextStepsView
steps={query.data.steps}
withdrawalUntil={query.data.withdrawalUntil}
@@ -12,8 +12,6 @@ import {
} from "@/lib/suggestions/api";
import { suggestionErrorText } from "@/lib/suggestions/errors";
/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */
const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!";
const UNDO_TOAST_MS = 8000;
/** Everything a decision can change on screen. */
const REFRESHED_KEYS = [
@@ -128,7 +126,6 @@ export function useStepActions(headingId: string) {
toast("Verworfen", {
duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) },
classNames: { actionButton: TOAST_ACTION_CLASS },
}),
onError: (error) => toast.error(suggestionErrorText(error)),
onSettled: async (_result, _error, step) => {
@@ -69,6 +69,11 @@ describe("DocumentUploadCard", () => {
vi.mocked(toast.error).mockReset();
});
it("has an upload button of at least 44 px", () => {
renderCard();
expect(screen.getByRole("button", { name: "Datei hochladen" })).toHaveClass("min-h-11");
});
it("re-encodes images before upload, PDFs unchanged", async () => {
const { container } = renderCard();
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
@@ -208,6 +208,7 @@ export function DocumentUploadCard({
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
@@ -110,15 +110,17 @@ describe("OfflineBand", () => {
);
});
it("tells that input is kept while offline with an empty queue", async () => {
it("tells what is kept while offline and that decisions need a connection", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand();
expect(await screen.findByRole("status")).toHaveTextContent(
"Offline – Eingaben werden gespeichert und später gesendet",
const status = await screen.findByRole("status");
expect(status).toHaveTextContent(
"Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung.",
);
expect(status).not.toHaveTextContent("Eingaben werden gespeichert");
});
it("keeps an empty status region online with an empty queue", async () => {
@@ -141,7 +141,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
: "Offline – Eingaben werden gespeichert und später gesendet"}
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { formatStand } from "@/lib/offline/stand";
import { OnlineOnlyHint, StandNote } from "./stand-note";
const UPDATED_AT = new Date(2026, 9, 14, 9, 5).getTime();
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
describe("formatStand", () => {
it("formats the time of the cached data as „Stand: TT.MM. HH:mm“", () => {
expect(formatStand(UPDATED_AT)).toBe("Stand: 14.10. 09:05");
});
});
describe("StandNote", () => {
it("shows nothing while online", () => {
render(<StandNote updatedAt={UPDATED_AT} />);
expect(screen.queryByText(/Stand:/)).not.toBeInTheDocument();
});
it("shows when the data is from while offline", () => {
act(() => reportRequestOutcome("network-error"));
render(<StandNote updatedAt={UPDATED_AT} />);
expect(screen.getByText("Stand: 14.10. 09:05")).toBeInTheDocument();
});
it("shows the Stand online when the reload failed and cached data is shown (stale)", () => {
render(<StandNote updatedAt={UPDATED_AT} isStale />);
expect(screen.getByText("Stand: 14.10. 09:05")).toBeInTheDocument();
});
it("shows nothing without a time (data never loaded)", () => {
act(() => reportRequestOutcome("network-error"));
render(<StandNote updatedAt={0} />);
expect(screen.queryByText(/Stand:/)).not.toBeInTheDocument();
});
});
describe("OnlineOnlyHint", () => {
it("says „Nur mit Verbindung“ only offline", () => {
const { rerender } = render(<OnlineOnlyHint id="h" />);
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
act(() => reportRequestOutcome("network-error"));
rerender(<OnlineOnlyHint id="h" />);
expect(screen.getByText("Nur mit Verbindung")).toHaveAttribute("id", "h");
});
});
@@ -0,0 +1,46 @@
"use client";
import { useId } from "react";
import { useConnectivity } from "@/lib/offline/connectivity";
import { formatStand } from "@/lib/offline/stand";
import { cn } from "@/lib/utils";
interface StandNoteProps {
/** `dataUpdatedAt` of the query (ms); 0 = never loaded. */
updatedAt: number;
/** Online, but the last reload failed and the cached data is shown. */
isStale?: boolean;
className?: string;
}
/** Data from the cache may be old (offline, or a failed reload): say since when. */
export function StandNote({ updatedAt, isStale = false, className }: StandNoteProps) {
const connectivity = useConnectivity();
if ((connectivity !== "offline" && !isStale) || updatedAt <= 0) return null;
return <p className={cn("text-sm text-muted-foreground", className)}>{formatStand(updatedAt)}</p>;
}
/**
* Offline state plus the id of the „Nur mit Verbindung“ hint: put `describedBy`
* on every button the hint explains, and render `<OnlineOnlyHint id={hintId} />`.
*/
export function useOnlineOnly(): {
isOffline: boolean;
hintId: string;
describedBy: string | undefined;
} {
const hintId = useId();
const isOffline = useConnectivity() === "offline";
return { isOffline, hintId, describedBy: isOffline ? hintId : undefined };
}
/** Offline, a decision that needs the server is off; the hint says why. */
export function OnlineOnlyHint({ id, className }: { id?: string; className?: string }) {
const connectivity = useConnectivity();
if (connectivity !== "offline") return null;
return (
<p id={id} className={cn("text-sm text-muted-foreground", className)}>
Nur mit Verbindung
</p>
);
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CleanupSheet } from "./cleanup-sheet";
@@ -10,6 +10,7 @@ import { fetchQueen } from "@/lib/queens/api";
import { fetchSuggestions } from "@/lib/suggestions/api";
import { fetchTask } from "@/lib/tasks/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Colony } from "@/lib/colonies/types";
import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types";
@@ -61,6 +62,14 @@ function renderSheet(onClose = vi.fn(), onDone = vi.fn()) {
return { onClose, onDone };
}
/** Waits for the task rows and gives each one the same choice. */
async function chooseForAllTasks(label: string) {
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
for (const group of screen.getAllByRole("radiogroup", { name: /^Aufgabe/ })) {
await userEvent.click(within(group).getByRole("radio", { name: label }));
}
}
describe("CleanupSheet", () => {
beforeEach(() => {
permissions.clear();
@@ -86,12 +95,18 @@ describe("CleanupSheet", () => {
});
});
it("CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup", async () => {
it("CleanupSheet starts without a task choice, keeps the button off until every task has one and sends apply-cleanup", async () => {
const { onClose } = renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(within(first).getByRole("radio", { name: "Abbrechen" })).toHaveAttribute("aria-checked", "true");
for (const radio of within(first).getAllByRole("radio")) {
expect(radio).toHaveAttribute("aria-checked", "false");
}
const second = screen.getByRole("radiogroup", { name: /Varroa messen/ });
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
expect(screen.getByText(/Bitte für jede Aufgabe etwas wählen/)).toBeInTheDocument();
await userEvent.click(within(first).getByRole("radio", { name: "Aufgabe abbrechen" }));
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
await userEvent.click(within(second).getByRole("radio", { name: "Behalten" }));
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
expect(within(queen).getByRole("radio", { name: "verstorben" })).toHaveAttribute("aria-checked", "true");
@@ -131,6 +146,7 @@ describe("CleanupSheet", () => {
expect(within(queen).getByRole("radio", { name: "unverändert" })).toHaveAttribute("aria-checked", "true"),
);
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -148,6 +164,7 @@ describe("CleanupSheet", () => {
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -182,6 +199,7 @@ describe("CleanupSheet", () => {
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
});
@@ -193,6 +211,7 @@ describe("CleanupSheet", () => {
message: "Nur Aufgaben und Königin aus dem Vorschlag.",
}),
);
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Nur Aufgaben und Königin aus dem Vorschlag."),
@@ -220,9 +239,32 @@ describe("CleanupSheet", () => {
vi.mocked(applyCleanup).mockRejectedValue(new ApiError(409, "Das Volk ist wieder aktiv.", {}));
const { toast } = await import("sonner");
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Das Volk ist wieder aktiv."));
expect(onClose).not.toHaveBeenCalled();
});
it("keeps „Aufgabe abbrechen“ apart from the close button „Schließen“", async () => {
renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(within(first).getByRole("radio", { name: "Aufgabe abbrechen" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument();
});
it("switches off the submit button in an open sheet when the connection drops", async () => {
renderSheet();
await chooseForAllTasks("Aufgabe abbrechen");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
act(() => reportRequestOutcome("network-error"));
const submit = screen.getByRole("button", { name: "Aufräumen" });
expect(submit).toBeDisabled();
expect(submit).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
act(() => reportRequestOutcome("reached-server"));
});
});
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eraser } from "lucide-react";
import { toast } from "sonner";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
Sheet,
@@ -28,8 +29,8 @@ import { ApiError } from "@/lib/auth/api-client";
type QueenChoice = "DECEASED" | "SOLD" | "KEEP";
const TASK_OPTIONS: ChoiceOption<CleanupOutcome>[] = [
{ value: "CANCEL", label: "Abbrechen" },
const TASK_OPTIONS: ChoiceOption<CleanupOutcome | null>[] = [
{ value: "CANCEL", label: "Aufgabe abbrechen" },
{ value: "DONE", label: "Erledigt" },
{ value: "KEEP", label: "Behalten" },
];
@@ -122,7 +123,7 @@ export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: Cleanu
showCloseButton={false}
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
>
{/* Remounted per opening, so the choices always start at the defaults. */}
{/* Remounted per opening, so the choices always start empty. */}
{suggestionId !== null ? (
<CleanupForm
key={suggestionId}
@@ -143,9 +144,11 @@ interface CleanupFormProps extends Omit<CleanupSheetProps, "suggestionId"> {
function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { hasPermission } = useAuth();
const canComplete = hasPermission("tasks:complete");
const canChangeQueen = hasPermission("queens:update");
// No row starts with a choice: cancelling a task must be a decision.
const [outcomes, setOutcomes] = useState<Readonly<Record<string, CleanupOutcome>>>({});
const [queenOverride, setQueenOverride] = useState<QueenChoice | null>(null);
// One key per set of choices: a retry after a lost answer is not applied twice.
@@ -183,8 +186,10 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
const goneCount = evidence.filter(({ task }) => task === null).length;
const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError));
const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status);
const hasAllChoices = taskRows.every(({ id }) => outcomes[id] !== undefined);
const isReady =
suggestion !== null &&
hasAllChoices &&
(taskIds.length === 0 || tasks.isSuccess) &&
(queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess)));
@@ -214,7 +219,10 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
}
function submit() {
mutation.mutate({
tasks: taskRows.map(({ id }) => ({ id, outcome: outcomes[id] ?? "CANCEL" })),
tasks: taskRows.flatMap(({ id }) => {
const outcome = outcomes[id];
return outcome ? [{ id, outcome }] : [];
}),
// „unverändert“ sends nothing: the queen keeps her status.
...(queenId !== null && queen.data && queenChoice !== "KEEP"
? { queen: { id: queenId, status: queenChoice } }
@@ -231,7 +239,7 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
<form
onSubmit={(event) => {
event.preventDefault();
if (isReady) submit();
if (isReady && !isOffline) submit();
}}
className="flex flex-col gap-4 px-4 pb-4"
>
@@ -285,13 +293,18 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
</p>
<ChoiceGroup
options={taskOptions}
value={outcomes[id] ?? "CANCEL"}
onChange={(outcome) => changeOutcome(id, outcome)}
value={outcomes[id] ?? null}
onChange={(outcome) => {
if (outcome !== null) changeOutcome(id, outcome);
}}
labelledBy={`cleanup-task-${id}`}
className="grid-cols-3"
/>
</div>
))}
{!hasAllChoices ? (
<p className="text-sm text-muted-foreground">Bitte für jede Aufgabe etwas wählen.</p>
) : null}
{!canComplete ? (
<p className="text-sm text-muted-foreground">
„Erledigt“ braucht das Recht „Aufgaben abschließen“.
@@ -325,8 +338,14 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
Aufräumen ist erst möglich, wenn alles geladen ist.
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse">
<Button type="submit" size="touch" disabled={!isReady || mutation.isPending}>
<Button
type="submit"
size="touch"
disabled={!isReady || mutation.isPending || isOffline}
aria-describedby={describedBy}
>
<Eraser aria-hidden />
{mutation.isPending ? "Wird gespeichert…" : "Aufräumen"}
</Button>
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { DueDateSheet } from "./due-date-sheet";
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
function setup() {
const onConfirm = vi.fn();
render(
<DueDateSheet
open
onOpenChange={() => undefined}
title="Termin ändern"
confirmLabel="Übernehmen"
initialDate="2026-05-12"
onConfirm={onConfirm}
/>,
);
return onConfirm;
}
describe("DueDateSheet", () => {
it("confirms with the day online", async () => {
const onConfirm = setup();
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
expect(onConfirm).toHaveBeenCalledWith("2026-05-12");
});
it("switches off the confirm button with „Nur mit Verbindung“ when the connection drops in the open sheet", async () => {
const onConfirm = setup();
act(() => reportRequestOutcome("network-error"));
const confirm = screen.getByRole("button", { name: "Übernehmen" });
expect(confirm).toBeDisabled();
expect(confirm).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -44,10 +45,11 @@ export function DueDateSheet(props: DueDateSheetProps) {
function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) {
const [date, setDate] = useState(initialDate);
const { isOffline, hintId, describedBy } = useOnlineOnly();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (date) onConfirm(date);
if (date && !isOffline) onConfirm(date);
}
return (
@@ -67,8 +69,9 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm
onChange={(event) => setDate(event.target.value)}
/>
</div>
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse">
<Button type="submit" size="touch" disabled={!date}>
<Button type="submit" size="touch" disabled={!date || isOffline} aria-describedby={describedBy}>
{confirmLabel}
</Button>
<SheetClose render={<Button type="button" size="touch" variant="outline" />}>
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionCard } from "./suggestion-card";
import { suggestionFixture } from "./suggestion-fixtures";
@@ -18,12 +19,31 @@ function setup(overrides = {}, handlers: Partial<Parameters<typeof SuggestionCar
return { onAccept, onDismiss };
}
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
describe("SuggestionCard", () => {
it("the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px", () => {
setup({ mode: "PLAN" });
expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveClass("h-11");
expect(screen.getByRole("button", { name: "Verwerfen" })).toHaveClass("h-11");
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("size-11");
const menu = screen.getByRole("button", { name: "Weitere Aktionen" });
expect(menu).toHaveClass("h-11");
expect(menu).toHaveClass("w-11");
expect(menu).toHaveClass("shrink-0");
});
it("fits the three buttons into 296 px (360 px phone minus page and card padding) and wraps instead of overflowing", () => {
setup({ mode: "PLAN" });
// Widths at 0.95rem text: Übernehmen ~136, Verwerfen without icon ~89, menu 44, two gaps 16 = 285.
const row = screen.getByRole("button", { name: "Übernehmen" }).parentElement as HTMLElement;
expect(row).toHaveClass("flex-wrap");
expect(row).not.toHaveClass("flex-nowrap");
expect(screen.getByRole("button", { name: "Verwerfen" }).querySelector("svg")).toHaveClass("hidden");
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("ml-auto");
expect(screen.getByRole("article")).toHaveClass("min-w-0");
});
it("shows „Erfassen“ as a real link to the capture form for DO", () => {
@@ -126,4 +146,54 @@ describe("SuggestionCard", () => {
expect(heading).toHaveAttribute("tabindex", "-1");
expect(heading).toHaveAttribute("data-suggestion-heading", "s1");
});
describe("offline", () => {
function goOffline() {
act(() => reportRequestOutcome("network-error"));
}
it("switches off the decisions with the hint „Nur mit Verbindung“", () => {
goOffline();
setup({ mode: "PLAN" });
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toBeDisabled();
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
});
it("leaves „Erfassen“ alone: captures are queued", () => {
goOffline();
setup({ mode: "DO", intent: "VARROA_MEASURE", colonyId: "c7" });
expect(screen.getByRole("link", { name: "Erfassen" })).toHaveAttribute(
"href",
"/varroa/new?colonyId=c7",
);
});
it("switches off „Aufräumen“", () => {
goOffline();
setup({ intent: "CLEANUP", mode: "DO" }, { onCleanup: vi.fn() });
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
});
it("ties the hint to the disabled buttons with aria-describedby", () => {
goOffline();
setup({ mode: "PLAN" });
const hint = screen.getByText("Nur mit Verbindung");
for (const name of ["Übernehmen", "Verwerfen", "Weitere Aktionen"]) {
expect(screen.getByRole("button", { name })).toHaveAttribute("aria-describedby", hint.id);
}
expect(hint.id).not.toBe("");
});
it("shows no hint online", () => {
setup({ mode: "PLAN" });
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
});
});
});
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DueChip } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels";
import type { Suggestion } from "@/lib/suggestions/types";
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
@@ -43,11 +44,25 @@ function menuActionOf(suggestion: Suggestion): MenuAction | null {
return { label: "Als Aufgabe planen", sheetTitle: "Als Aufgabe planen", confirmLabel: "Aufgabe anlegen" };
}
function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: SuggestionCardProps) {
/** Offline, deciding is off: the server has to confirm it. */
function MainAction({
suggestion,
busy,
canAccept = true,
isOffline,
describedBy,
onAccept,
onCleanup,
}: SuggestionCardProps & { isOffline: boolean; describedBy?: string }) {
if (suggestion.intent === "CLEANUP") {
if (!onCleanup) return null;
return (
<Button size="touch" disabled={busy} onClick={() => onCleanup(suggestion)}>
<Button
size="touch"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onCleanup(suggestion)}
>
<Check aria-hidden />
Aufräumen
</Button>
@@ -66,7 +81,13 @@ function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }:
}
if (!canAccept) return null;
return (
<Button size="touch" disabled={busy} onClick={() => onAccept(suggestion, undefined)}>
<Button
size="touch"
className="px-3.5"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onAccept(suggestion, undefined)}
>
<Check aria-hidden />
Übernehmen
</Button>
@@ -79,10 +100,14 @@ export function SuggestionCard(props: SuggestionCardProps) {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const Icon = INTENT_ICONS[suggestion.intent];
const menuAction = canAccept ? menuActionOf(suggestion) : null;
const { isOffline, hintId, describedBy } = useOnlineOnly();
const mainAction =
suggestion.intent === "CLEANUP" ? Boolean(props.onCleanup) : suggestion.mode === "PLAN" && canAccept;
const hasOnlineOnlyButton = mainAction || canDismiss || menuAction !== null;
const isUrgent = suggestion.priority === "HIGH" || suggestion.priority === "URGENT";
return (
<article className="flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" aria-hidden />
@@ -120,16 +145,24 @@ export function SuggestionCard(props: SuggestionCardProps) {
</div>
</div>
</div>
{/*
360 px phone: 360 - 2*16 (page) - 2*16 (card) = 296 px. Übernehmen ~136 +
Verwerfen without icon ~89 + menu 44 + 2 gaps 16 = 285 px: all three fit in
one row. If a longer label ever does not, the row wraps instead of pushing
the menu out of the card.
*/}
<div className="flex flex-wrap items-center gap-2">
<MainAction {...props} />
<MainAction {...props} isOffline={isOffline} describedBy={describedBy} />
{canDismiss ? (
<Button
size="touch"
variant="ghost"
disabled={busy}
className="px-3"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onDismiss(suggestion)}
>
<X aria-hidden />
<X aria-hidden className="hidden min-[420px]:block" />
Verwerfen
</Button>
) : null}
@@ -138,9 +171,11 @@ export function SuggestionCard(props: SuggestionCardProps) {
<DropdownMenuTrigger
render={
<Button
size="touch"
variant="ghost"
className="size-11"
disabled={busy}
className="ml-auto w-11 shrink-0 px-0"
disabled={busy || isOffline}
aria-describedby={describedBy}
aria-label="Weitere Aktionen"
/>
}
@@ -155,6 +190,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
</DropdownMenu>
) : null}
</div>
{hasOnlineOnlyButton ? <OnlineOnlyHint id={hintId} /> : null}
{menuAction ? (
<DueDateSheet
open={isSheetOpen}
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { groupSuggestions } from "@/lib/suggestions/grouping";
import type { Suggestion } from "@/lib/suggestions/types";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionGroup } from "./suggestion-group";
import { suggestionFixture } from "./suggestion-fixtures";
@@ -138,4 +139,25 @@ describe("SuggestionGroup", () => {
expect(accept[0]).toBeDisabled();
expect(accept[1]).toBeEnabled();
});
it("switches off „Alle übernehmen“ offline and says why", () => {
act(() => reportRequestOutcome("network-error"));
try {
setup(THREE_PLAN);
const button = screen.getByRole("button", { name: "Alle übernehmen" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
} finally {
act(() => reportRequestOutcome("reached-server"));
}
});
it("the chevron moves only where motion is allowed", () => {
setup(THREE_PLAN);
const chevron = screen.getByRole("button", { name: /Einzeln anzeigen/ }).querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
});
@@ -3,6 +3,7 @@
import { useId, useState } from "react";
import Link from "next/link";
import { Check, ChevronDown, Play } from "lucide-react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button, buttonVariants } from "@/components/ui/button";
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
@@ -55,6 +56,7 @@ function CombinedRow({
const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
const canAcceptAll =
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
return (
@@ -80,7 +82,8 @@ function CombinedRow({
) : canAcceptAll ? (
<Button
size="touch"
disabled={isBusy}
disabled={isBusy || isOffline}
aria-describedby={describedBy}
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
>
<Check aria-hidden />
@@ -88,6 +91,7 @@ function CombinedRow({
</Button>
) : null}
</div>
{canAcceptAll && !round ? <OnlineOnlyHint id={hintId} /> : null}
<Button
variant="ghost"
className="min-h-11 self-start"
@@ -97,7 +101,7 @@ function CombinedRow({
>
<ChevronDown
aria-hidden
className={cn("transition-transform", isOpen && "rotate-180")}
className={cn("motion-safe:transition-transform", isOpen && "rotate-180")}
/>
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button>
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TabSwitcher } from "./tab-switcher";
describe("TabSwitcher", () => {
it("makes every tab at least 44 px high for a thumb", () => {
render(
<TabSwitcher
tabs={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
value="a"
onChange={() => {}}
/>,
);
for (const tab of screen.getAllByRole("tab")) {
expect(tab).toHaveClass("min-h-11", "sm:min-h-9");
}
});
});
@@ -20,7 +20,7 @@ export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwit
aria-selected={value === tab.value}
onClick={() => onChange(tab.value)}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
"min-h-11 sm:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
value === tab.value
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground",
@@ -1,11 +1,13 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { StepActions } from "@/components/autopilot/next-steps-card";
import { useStepActions } from "@/components/autopilot/use-step-actions";
@@ -13,6 +15,7 @@ import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { dueChipProps } from "@/lib/autopilot/format";
import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { InspectionDueCard } from "./inspection-due-card";
@@ -20,6 +23,20 @@ import { useTaskActions } from "./use-task-actions";
const TODAY_LIST_LIMIT = 5;
/**
* Code per colony id for the task rows: the colonies list is one request
* (shared with the colonies page), asked for only when a task belongs to a colony.
*/
function useColonyCodes(tasks: Task[] | undefined): ReadonlyMap<string, string> {
const hasColonyTask = tasks?.some((task) => task.relatedType === "COLONY" && task.relatedId) ?? false;
const query = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies({}),
enabled: hasColonyTask,
});
return new Map((query.data ?? []).map((colony) => [colony.id, colony.code]));
}
const OPEN_RELATED_LABEL: Record<TaskRelatedType, string> = {
COLONY: "Zum Volk",
APIARY: "Zum Bienenstand",
@@ -50,6 +67,7 @@ export function TodayPanel({
const actions = useTaskActions();
const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth();
const colonyCodes = useColonyCodes(loadedTasks);
// The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
@@ -139,7 +157,7 @@ export function TodayPanel({
</h2>
<Link
href="/tasks"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
className="inline-flex min-h-11 items-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Alle Aufgaben
</Link>
@@ -166,6 +184,7 @@ export function TodayPanel({
task={task}
now={now}
leaving={actions.leavingIds.has(task.id)}
colonyCode={task.relatedType === "COLONY" && task.relatedId ? colonyCodes.get(task.relatedId) : undefined}
onComplete={() => actions.complete(task)}
/>
))}
@@ -174,7 +193,7 @@ export function TodayPanel({
{hidden > 0 ? (
<Link
href="/tasks"
className="self-start text-sm text-muted-foreground underline-offset-4 hover:underline"
className="inline-flex min-h-11 items-center self-start text-sm text-muted-foreground underline-offset-4 hover:underline"
>
+ {hidden} weitere für heute
</Link>
@@ -195,6 +214,7 @@ interface NextStepCardProps {
function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<section
aria-labelledby="next-step-heading"
@@ -222,7 +242,12 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="touch" onClick={onComplete} disabled={leaving}>
<Button
size="touch"
onClick={onComplete}
disabled={leaving || isOffline}
aria-describedby={describedBy}
>
<Check aria-hidden />
{leaving ? "Wird gespeichert…" : "Erledigt"}
</Button>
@@ -231,7 +256,8 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
size="touch"
variant="outline"
onClick={onStart}
disabled={isStarting || leaving}
disabled={isStarting || leaving || isOffline}
aria-describedby={describedBy}
>
<Play aria-hidden />
Beginnen
@@ -243,6 +269,7 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
<ArrowRight aria-hidden />
</Button>
) : null}
<OnlineOnlyHint id={hintId} className="basis-full" />
</div>
</div>
</section>
@@ -352,11 +379,14 @@ interface TodayRowProps {
task: Task;
now: Date;
leaving: boolean;
/** Code of the colony the task is about, when known. */
colonyCode?: string;
onComplete: () => void;
}
function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<li className="today-row" data-leaving={leaving}>
<div>
@@ -367,7 +397,8 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
aria-checked={leaving}
aria-label={`„${task.title}“ als erledigt markieren`}
className="honey-check"
disabled={leaving}
disabled={leaving || isOffline}
aria-describedby={describedBy}
onClick={onComplete}
>
<svg
@@ -385,10 +416,16 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
@@ -0,0 +1,65 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { toast } from "sonner";
import { Toaster } from "./sonner";
beforeAll(() => {
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(() => {
act(() => {
toast.dismiss();
});
});
describe("Toaster", () => {
it("sits at the bottom with a phone offset above the bottom nav and the quick-add button", async () => {
const { container } = render(<Toaster />);
act(() => {
toast("Verworfen");
});
await screen.findByText("Verworfen");
const list = container.ownerDocument.querySelector("[data-sonner-toaster]") as HTMLElement;
expect(list).toHaveAttribute("data-y-position", "bottom");
// One logic for both breakpoints: the CSS variable follows the app's sm breakpoint (640 px).
expect(list.style.getPropertyValue("--mobile-offset-bottom")).toBe("var(--toast-offset-bottom)");
expect(list.style.getPropertyValue("--offset-bottom")).toBe("var(--toast-offset-bottom)");
});
it("makes the action button of every toast 44 px high", async () => {
render(<Toaster />);
act(() => {
toast("Verworfen", { action: { label: "Rückgängig", onClick: () => undefined } });
});
const action = await screen.findByRole("button", { name: "Rückgängig" });
expect(action.className).toContain("h-11!");
expect(action.className).toContain("min-w-11!");
});
it("makes the close button a 44 px target", async () => {
render(<Toaster closeButton />);
act(() => {
toast("Hinweis");
});
await screen.findByText("Hinweis");
const close = document.querySelector("[data-close-button]") as HTMLElement;
expect(close.className).toContain("size-11!");
});
});
@@ -4,6 +4,13 @@ import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
// Toasts sit at the bottom: at the top they cover the page heading and the
// notification bell. One offset for every width: --toast-offset-bottom
// (globals.css) clears the bottom nav and the quick-add button below the app's
// sm breakpoint (640 px) and the sticky save bar everywhere. Sonner switches to
// its phone layout at 600 px, so both offsets take the same variable.
const TOAST_OFFSET = { bottom: "var(--toast-offset-bottom)", left: 16, right: 16 };
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
@@ -11,6 +18,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position="bottom-right"
offset={TOAST_OFFSET}
mobileOffset={TOAST_OFFSET}
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -39,6 +49,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast: "cn-toast",
// Sonner's own buttons are 24 px high; a tap target needs 44 px.
actionButton: "h-11! min-h-11! min-w-11! px-4!",
closeButton: "size-11!",
cancelButton: "h-11! min-h-11! min-w-11! px-4!",
},
}}
{...props}
@@ -7,7 +7,7 @@ const warnings: PlausibilityWarning[] = [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [{ type: "Colony", id: "c-1" }],
},
{
@@ -28,7 +28,7 @@ describe("WarningList", () => {
expect(within(region).getByText("Warnung")).toBeInTheDocument();
expect(within(region).getByText("Hinweis")).toBeInTheDocument();
expect(
within(region).getByText("B3: Wartezeit nach Oxuvar endet am 20.08.2026."),
within(region).getByText("B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig."),
).toBeInTheDocument();
expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2);
});
@@ -11,6 +11,7 @@ import {
getPack,
isPackStale,
listPacks,
livingColoniesFromPacks,
loadPack,
packAgeText,
purgeOldPacks,
@@ -308,3 +309,14 @@ describe("offline packs", () => {
});
});
});
describe("livingColoniesFromPacks order", () => {
it("sorts colony codes naturally (V-2 before V-10)", () => {
const pack = storedPack(
offlinePack({
colonies: [packColony("c1", "V-10"), packColony("c2", "V-2"), packColony("c3", "V-010")],
}),
);
expect(livingColoniesFromPacks([pack]).map((colony) => colony.code)).toEqual(["V-2", "V-010", "V-10"]);
});
});
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { isLivingColony } from "@/lib/colonies/status";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
@@ -221,9 +222,7 @@ export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColon
}
}
}
return [...byId.values()].sort((a, b) =>
a.code.localeCompare(b.code, "de", { numeric: true }),
);
return [...byId.values()].sort((a, b) => compareColonyCodes(a.code, b.code));
}
/**
@@ -1,4 +1,4 @@
import { AutomationTriggerType } from "./types";
import { AutomationRule, AutomationTriggerType } from "./types";
export const RULE_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
@@ -307,3 +307,20 @@ export const SIMPLE_RULE_NOTIFY_DEFAULTS: Partial<Record<AutomationTriggerType,
TREATMENT_FOLLOW_UP: false,
FEEDING_FOLLOW_UP: false,
};
/**
* One line for the closed rule card: the due time and the extra notification
* when the rule has them, else what the rule does (the badge shows on/off).
*/
export function ruleSummary(rule: AutomationRule): string {
const config = (rule.config ?? {}) as { dueInDays?: unknown; notify?: unknown; format?: unknown };
const parts: string[] = [];
if (typeof config.format === "string" && config.format !== "") parts.push(`Format ${config.format}`);
if (typeof config.dueInDays === "number") {
parts.push(`Frist ${config.dueInDays} ${config.dueInDays === 1 ? "Tag" : "Tage"}`);
}
const notify =
typeof config.notify === "boolean" ? config.notify : SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType];
if (notify) parts.push("meldet zusätzlich");
return parts.length > 0 ? parts.join(" · ") : RULE_DESCRIPTIONS[rule.triggerType];
}
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { compareColonyCodes } from "@/lib/colonies/sort";
import { toCalendarDay } from "@/lib/dates";
export interface BottlingWithdrawalHint {
colonyId: string;
colonyCode: string;
product: string;
/** Server text: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
withdrawalText: string;
}
/**
* The withdrawal windows (Wartezeit) the honey of a batch was harvested in:
* each harvest is checked at its own day for its own colonies. One hint per
* colony and treatment, however many harvests of the batch it fed.
*/
export async function loadBottlingWithdrawalHints(
honeyBatchId: string,
): Promise<BottlingWithdrawalHint[]> {
const { harvests } = await fetchHoneyBatchTraceability(honeyBatchId);
const perHarvest = await Promise.all(
harvests.map(async (harvest) => {
const statuses = await fetchWithdrawalStatus(
harvest.colonies.map((colony) => colony.id),
toCalendarDay(harvest.date),
);
return statuses.flatMap((status) => {
const colony = harvest.colonies.find((candidate) => candidate.id === status.colonyId);
return colony ? [{ ...status, colonyCode: colony.code }] : [];
});
}),
);
const unique = new Map<string, BottlingWithdrawalHint>();
for (const status of perHarvest.flat()) {
unique.set(`${status.colonyId}|${status.product}|${status.withdrawalText}`, status);
}
return [...unique.values()].sort((left, right) =>
compareColonyCodes(left.colonyCode, right.colonyCode),
);
}
/** Hints for the chosen honey batch; `failed` when the check could not be made. */
export function useBottlingWithdrawalHints(honeyBatchId: string): {
hints: BottlingWithdrawalHint[];
failed: boolean;
} {
const query = useQuery({
queryKey: ["honey-batches", honeyBatchId, "withdrawal-hints"],
queryFn: () => loadBottlingWithdrawalHints(honeyBatchId),
enabled: honeyBatchId !== "",
});
return { hints: query.data ?? [], failed: query.isError };
}
@@ -1,4 +1,5 @@
"use client";
import { compareColonyCodes } from "@/lib/colonies/sort";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
@@ -28,7 +29,7 @@ interface Choices<T> {
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
function byCode(a: BulkColony, b: BulkColony): number {
return a.code.localeCompare(b.code, "de", { numeric: true });
return compareColonyCodes(a.code, b.code);
}
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
@@ -16,6 +16,12 @@ export interface CalendarEvent {
title: string;
relatedType: string;
relatedId: string;
/** Colony code, or apiary name for harvests and apiary tasks; absent for general tasks. */
context?: string;
/** WITHDRAWAL only: the treatment product. */
product?: string;
/** WITHDRAWAL only: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
detail?: string;
}
export interface CalendarQuery {
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchColonies } from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
describe("fetchColonies", () => {
it("returns the colonies in natural code order (V-02 and V-2 before V-10)", async () => {
vi.mocked(apiFetch).mockResolvedValue([{ code: "V-010" }, { code: "V-2" }, { code: "V-10" }, { code: "V-02" }]);
const colonies = await fetchColonies();
expect(colonies.map((colony) => colony.code)).toEqual(["V-02", "V-2", "V-010", "V-10"]);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import { sortColoniesByCode } from "./sort";
import {
Colony,
ColonyHistory,
@@ -20,7 +21,7 @@ export function fetchColonies(filter: ColonyFilter = {}): Promise<Colony[]> {
if (filter.status) params.set("status", filter.status);
if (filter.apiaryId) params.set("apiaryId", filter.apiaryId);
const query = params.toString();
return apiFetch<Colony[]>(`/colonies${query ? `?${query}` : ""}`);
return apiFetch<Colony[]>(`/colonies${query ? `?${query}` : ""}`).then(sortColoniesByCode);
}
export function fetchColony(id: string): Promise<Colony> {
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { compareColonyCodes, sortColoniesByCode } from "./sort";
describe("colony code order", () => {
it("sorts numbers inside codes by value, not letter by letter", () => {
const codes = ["V-010", "V-10", "V-2", "V-02", "V-1"];
expect([...codes].sort(compareColonyCodes)).toEqual(["V-1", "V-02", "V-2", "V-010", "V-10"]);
});
it("returns a sorted copy and leaves the input alone", () => {
const colonies = [{ code: "V-10" }, { code: "V-9" }];
const sorted = sortColoniesByCode(colonies);
expect(sorted.map((colony) => colony.code)).toEqual(["V-9", "V-10"]);
expect(colonies.map((colony) => colony.code)).toEqual(["V-10", "V-9"]);
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Colony codes in the order a beekeeper counts them: V-2 before V-10, not
* letter by letter. One collator, shared by every picker and list.
*/
const CODE_COLLATOR = new Intl.Collator("de", { numeric: true, sensitivity: "base" });
export function compareColonyCodes(left: string, right: string): number {
// Same number written differently (V-02, V-2): fall back to the text, so the order is stable.
return CODE_COLLATOR.compare(left, right) || (left < right ? -1 : left > right ? 1 : 0);
}
/** A copy of `colonies` in natural code order; the input stays as it is. */
export function sortColoniesByCode<T extends { code: string }>(colonies: readonly T[]): T[] {
return [...colonies].sort((left, right) => compareColonyCodes(left.code, right.code));
}
@@ -0,0 +1,7 @@
const pad = (value: number): string => String(value).padStart(2, "0");
/** „Stand: 14.10. 09:05“ - when the data shown from the cache was loaded (local time). */
export function formatStand(updatedAt: number): string {
const date = new Date(updatedAt);
return `Stand: ${pad(date.getDate())}.${pad(date.getMonth() + 1)}. ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
@@ -88,6 +88,14 @@ describe("roundTarget", () => {
expect(target?.href).toBe("/inspections/new?colonyId=c2&round=a1");
});
it("sorts codes with long numbers naturally (V-2 before V-010)", () => {
const target = roundTarget([
suggestion({ id: "1", colonyId: "c1", colonyCode: "V-010" }),
suggestion({ id: "2", colonyId: "c2", colonyCode: "V-2" }),
]);
expect(target?.colonies.map((colony) => colony.code)).toEqual(["V-2", "V-010"]);
});
it("is null without a colony at an apiary", () => {
expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull();
expect(roundTarget([])).toBeNull();
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import type { Suggestion, SuggestionIntent } from "./types";
export type SuggestionRow =
@@ -92,7 +93,7 @@ export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | n
if (apiary === null) return null;
const sorted = [...colonies.entries()]
.map(([id, code]) => ({ id, code }))
.sort((a, b) => compareText(a.code, b.code));
.sort((a, b) => compareColonyCodes(a.code, b.code));
const href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`;
const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`;
return { apiary, colonies: sorted, href, plainHref };
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { entryCountText } from "./plural";
describe("entryCountText", () => {
it("uses the singular for exactly one entry", () => {
expect(entryCountText(0)).toBe("0 Einträge");
expect(entryCountText(1)).toBe("1 Eintrag");
expect(entryCountText(2)).toBe("2 Einträge");
});
});
+4
View File
@@ -0,0 +1,4 @@
/** "1 Eintrag", "0 Einträge", "2 Einträge". */
export function entryCountText(count: number): string {
return `${count} ${count === 1 ? "Eintrag" : "Einträge"}`;
}
@@ -60,7 +60,12 @@ export interface UpdateTreatmentInput {
export interface WithdrawalStatus {
colonyId: string;
/** First day honey may be taken again (ISO). */
activeUntil: string;
/** Last blocked day, inclusive (YYYY-MM-DD). */
lastBlockedDay: string;
/** Ready text from the server: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
withdrawalText: string;
product: string;
}
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({ toast: { warning: vi.fn() } }));
const withdrawal: PlausibilityWarning = {
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [],
};
@@ -20,7 +20,7 @@ describe("toastWarnings", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Gespeichert · 1 Warnung",
expect.objectContaining({
description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
description: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
}),
);
});
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more