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
+2189 -271

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 ### 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. **`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 = { const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE', code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG', 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' }], refs: [{ type: 'Colony', id: 'c1' }],
}; };
@@ -3,6 +3,14 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('CalendarService', () => { 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 service: CalendarService;
let prisma: { let prisma: {
task: { findMany: jest.Mock }; task: { findMany: jest.Mock };
@@ -11,6 +19,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.Mock }; feeding: { findMany: jest.Mock };
harvest: { findMany: jest.Mock }; harvest: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock }; queenColonyHistory: { findMany: jest.Mock };
colony: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
}; };
let resolver: { getPermissions: jest.Mock }; let resolver: { getPermissions: jest.Mock };
@@ -24,6 +34,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.fn().mockResolvedValue([]) }, feeding: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) }, harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) }, queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
}; };
service = new CalendarService( service = new CalendarService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
@@ -44,7 +56,12 @@ describe('CalendarService', () => {
}, },
]); ]);
prisma.harvest.findMany.mockResolvedValueOnce([ 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( 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 () => { it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents( await service.getEvents(
{ {
@@ -135,6 +219,9 @@ describe('CalendarService', () => {
title: 'Wartezeit V-001 · Oxuvar', title: 'Wartezeit V-001 · Oxuvar',
relatedType: 'Colony', relatedType: 'Colony',
relatedId: 'c1', 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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { addDays, berlinDayOf, dayToDate } from '../common/dates'; import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span'; import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
type TreatmentWithColony = Prisma.TreatmentGetPayload<{ type TreatmentWithColony = Prisma.TreatmentGetPayload<{
include: { colony: true }; include: { colony: true };
@@ -25,6 +29,12 @@ export interface CalendarEvent {
/** WITHDRAWAL only: the last day the span covers (inclusive). */ /** WITHDRAWAL only: the last day the span covers (inclusive). */
endDate?: Date; endDate?: Date;
title: string; 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; relatedType: string;
relatedId: string; relatedId: string;
} }
@@ -71,7 +81,7 @@ export class CalendarService {
if (span === null) { if (span === null) {
return []; return [];
} }
const lastDay = addDays(span.untilDay, -1); const lastDay = lastBlockedDay(span.untilDay);
if (span.fromDay > toDay || lastDay < fromDay) { if (span.fromDay > toDay || lastDay < fromDay) {
return []; return [];
} }
@@ -82,6 +92,9 @@ export class CalendarService {
date: dayToDate(span.fromDay), date: dayToDate(span.fromDay),
endDate: dayToDate(lastDay), endDate: dayToDate(lastDay),
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`, title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
product: treatment.product,
detail: withdrawalRangeText(span.untilDay),
relatedType: 'Colony', relatedType: 'Colony',
relatedId: treatment.colonyId, 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( async getEvents(
query: CalendarQuery, query: CalendarQuery,
userId: string, userId: string,
@@ -132,6 +199,7 @@ export class CalendarService {
date: { gte: from, lte: to }, date: { gte: from, lte: to },
...(apiaryId ? { apiaryId } : {}), ...(apiaryId ? { apiaryId } : {}),
}, },
include: { apiary: { select: { name: true } } },
}), }),
this.prisma.queenColonyHistory.findMany({ this.prisma.queenColonyHistory.findMany({
where: { startDate: { gte: from, lte: to }, ...colonyFilter }, where: { startDate: { gte: from, lte: to }, ...colonyFilter },
@@ -140,6 +208,8 @@ export class CalendarService {
this.withdrawalEvents(query, canReadTreatments), this.withdrawalEvents(query, canReadTreatments),
]); ]);
const taskContexts = await this.taskContexts(tasks);
const events: CalendarEvent[] = [ const events: CalendarEvent[] = [
...tasks ...tasks
.filter((task) => task.dueDate !== null) .filter((task) => task.dueDate !== null)
@@ -148,6 +218,7 @@ export class CalendarService {
type: 'TASK' as const, type: 'TASK' as const,
date: task.dueDate!, date: task.dueDate!,
title: task.title, title: task.title,
context: taskContexts.get(task.id),
relatedType: 'Task', relatedType: 'Task',
relatedId: task.id, relatedId: task.id,
})), })),
@@ -156,6 +227,7 @@ export class CalendarService {
type: 'INSPECTION' as const, type: 'INSPECTION' as const,
date: inspection.date, date: inspection.date,
title: `Durchsicht ${inspection.colony.code}`, title: `Durchsicht ${inspection.colony.code}`,
context: inspection.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: inspection.colonyId, relatedId: inspection.colonyId,
})), })),
@@ -164,6 +236,7 @@ export class CalendarService {
type: 'TREATMENT' as const, type: 'TREATMENT' as const,
date: treatment.date, date: treatment.date,
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`, title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: treatment.colonyId, relatedId: treatment.colonyId,
})), })),
@@ -172,6 +245,7 @@ export class CalendarService {
type: 'FEEDING' as const, type: 'FEEDING' as const,
date: feeding.date, date: feeding.date,
title: `Fütterung ${feeding.colony.code}`, title: `Fütterung ${feeding.colony.code}`,
context: feeding.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: feeding.colonyId, relatedId: feeding.colonyId,
})), })),
@@ -180,6 +254,7 @@ export class CalendarService {
type: 'HARVEST' as const, type: 'HARVEST' as const,
date: harvest.date, date: harvest.date,
title: 'Ernte', title: 'Ernte',
context: harvest.apiary.name,
relatedType: 'Apiary', relatedType: 'Apiary',
relatedId: harvest.apiaryId, relatedId: harvest.apiaryId,
})), })),
@@ -188,6 +263,7 @@ export class CalendarService {
type: 'QUEEN_ASSIGNMENT' as const, type: 'QUEEN_ASSIGNMENT' as const,
date: assignment.startDate, date: assignment.startDate,
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`, title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
context: assignment.colony.code,
relatedType: 'Queen', relatedType: 'Queen',
relatedId: assignment.queenId, 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 = { const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE', code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG', 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' }], refs: [{ type: 'Colony', id: 'c1' }],
}; };
@@ -260,7 +260,8 @@ describe('HarvestsService', () => {
const warning: PlausibilityWarning = { const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE', code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG', 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' }], refs: [{ type: 'Colony', id: 'colony-1' }],
}; };
@@ -106,5 +106,5 @@ export function renderSuggestion(
return override ? { ...rendered, title: override } : rendered; return override ? { ...rendered, title: override } : rendered;
} }
export { formatDayDe } from './texts/format-day'; export { formatDayDe } from '../common/format-day';
export { formatNumberDe } from './texts/format-number'; export { formatNumberDe } from './texts/format-number';
@@ -28,3 +28,35 @@ describe('chain texts', () => {
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle'); 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, SuggestionTextRegister,
SuggestionTextTemplate, SuggestionTextTemplate,
} from '../suggestion-texts'; } from '../suggestion-texts';
import { formatDayDe } from './format-day'; import { endSentence, formatDayDe } from '../../common/format-day';
import type { EvidenceLookup } from '../evidence-lookup'; import type { EvidenceLookup } from '../evidence-lookup';
import type { SuggestionEvidence } from '../suggestion.types'; import type { SuggestionEvidence } from '../suggestion.types';
@@ -39,7 +39,9 @@ function splitTemplate(
render: (evidence, lookup) => ({ render: (evidence, lookup) => ({
title, title,
reasons: [ reasons: [
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`, endSentence(
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}`,
),
reason, reason,
], ],
}), }),
@@ -59,7 +61,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
render: (evidence, lookup) => ({ render: (evidence, lookup) => ({
title: 'Schwarmkontrolle', title: 'Schwarmkontrolle',
reasons: [ 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 { return {
title: 'Annahme der Königin prüfen', title: 'Annahme der Königin prüfen',
reasons: [ 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 type { SuggestionTextRegister } from '../suggestion-texts';
import { formatDayDe } from './format-day'; import { formatDayDe } from '../../common/format-day';
const ENDED_VERBS: Readonly<Record<string, string>> = { const ENDED_VERBS: Readonly<Record<string, string>> = {
DECEASED: 'ist eingegangen', 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 { formatNumberDe } from './format-number';
import type { import type {
RenderedSuggestionText, RenderedSuggestionText,
@@ -6,6 +6,14 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('TreatmentsService', () => { 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 service: TreatmentsService;
let prisma: { let prisma: {
treatment: { findMany: jest.Mock; findUnique: jest.Mock }; treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -499,6 +507,9 @@ describe('TreatmentsService', () => {
{ {
colonyId: 'colony-1', colonyId: 'colony-1',
activeUntil: '2026-07-15T00:00:00.000Z', 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', product: 'Ameisensäure',
}, },
]); ]);
@@ -587,6 +598,9 @@ describe('TreatmentsService', () => {
{ {
colonyId: 'colony-1', colonyId: 'colony-1',
activeUntil: '2026-07-27T00:00:00.000Z', 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', product: 'Ameisensäure',
}, },
]); ]);
@@ -15,6 +15,10 @@ import {
import { runBulk } from '../common/bulk/run-bulk'; import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate } from '../prisma/row-lock';
import { berlinDayOf, dayToDate } from '../common/dates'; import { berlinDayOf, dayToDate } from '../common/dates';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
import { import {
isInWindow, isInWindow,
withdrawalWindowOf, withdrawalWindowOf,
@@ -27,6 +31,10 @@ import { UpdateTreatmentDto } from './dto/update-treatment.dto';
export interface WithdrawalStatus { export interface WithdrawalStatus {
colonyId: string; colonyId: string;
activeUntil: 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; product: string;
} }
@@ -116,6 +124,8 @@ export class TreatmentsService {
return [...activeByColony.values()].map((window) => ({ return [...activeByColony.values()].map((window) => ({
colonyId: window.colonyId, colonyId: window.colonyId,
activeUntil: dayToDate(window.untilDay).toISOString(), activeUntil: dayToDate(window.untilDay).toISOString(),
lastBlockedDay: lastBlockedDay(window.untilDay),
withdrawalText: withdrawalRangeText(window.untilDay),
product: window.product, product: window.product,
})); }));
} }
@@ -40,6 +40,14 @@ const conflict = (
}); });
describe('WithdrawalCheckService', () => { 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: { let db: {
harvest: { findMany: jest.Mock }; harvest: { findMany: jest.Mock };
treatment: { findMany: jest.Mock; findUnique: jest.Mock }; treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -269,7 +277,8 @@ describe('WithdrawalCheckService', () => {
{ {
code: 'WITHDRAWAL_ACTIVE', code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG', 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: [ refs: [
{ type: 'Colony', id: 'c1' }, { type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' }, { 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 () => { it('answers nothing without reading when there is no conflict', async () => {
expect(await service.warningsFor(asDb(), [])).toEqual([]); expect(await service.warningsFor(asDb(), [])).toEqual([]);
expect(db.colony.findMany).not.toHaveBeenCalled(); expect(db.colony.findMany).not.toHaveBeenCalled();
@@ -300,7 +328,7 @@ describe('WithdrawalCheckService', () => {
priority: 'WARNUNG', priority: 'WARNUNG',
category: 'WITHDRAWAL', category: 'WITHDRAWAL',
message: 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', relatedType: 'Harvest',
relatedId: 'h1', relatedId: 'h1',
link: '/harvests/h1', link: '/harvests/h1',
@@ -337,7 +365,7 @@ describe('WithdrawalCheckService', () => {
{ message: string; dedupeKeyPrefix: string }, { message: string; dedupeKeyPrefix: string },
]; ];
expect(input.message).toBe( 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. // Same key as without the lots: one notification per harvest and colony.
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1'); expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
@@ -7,13 +7,14 @@ import {
} from '../common/warnings/plausibility-warning'; } from '../common/warnings/plausibility-warning';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { assertTransactionClient, type DbClient } from '../prisma/db-client'; import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { formatDayDe } from '../suggestions/texts/format-day'; import { formatDayDe } from '../common/format-day';
import { import {
isInWindow, isInWindow,
withdrawalWindowOf, withdrawalWindowOf,
type WithdrawalWindow, type WithdrawalWindow,
} from '../treatments/withdrawal-window'; } from '../treatments/withdrawal-window';
import { withdrawalSpanOf } from './withdrawal-span'; import { withdrawalSpanOf } from './withdrawal-span';
import { withdrawalRangeText } from './withdrawal-text';
/** A harvest taken from a colony while a treatment's withdrawal period ran. */ /** A harvest taken from a colony while a treatment's withdrawal period ran. */
export interface WithdrawalConflict { 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 * Checks harvests against the withdrawal windows (Wartezeit) of their
* colonies. Only the window helper decides what a window is * colonies. Only the window helper decides what a window is
@@ -190,16 +207,22 @@ export class WithdrawalCheckService {
return []; return [];
} }
const codes = await this.colonyCodes(db, conflicts); const codes = await this.colonyCodes(db, conflicts);
return conflicts.map((conflict) => ({ return perColonyAndTreatment(conflicts).map((group) => {
code: 'WITHDRAWAL_ACTIVE', const [first] = group;
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE, return {
message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`, code: 'WITHDRAWAL_ACTIVE',
refs: [ severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
{ type: 'Colony', id: conflict.colonyId }, message: `${codes.get(first.colonyId) ?? first.colonyId}: Wartezeit nach ${first.product} ${withdrawalRangeText(first.untilDay)}.`,
{ type: 'Treatment', id: conflict.treatmentId }, refs: [
{ 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', priority: 'WARNUNG',
category: 'WITHDRAWAL', 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', relatedType: 'Harvest',
relatedId: conflict.harvestId, relatedId: conflict.harvestId,
link: `/harvests/${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', () => { 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: { let check: {
conflictsForHarvests: jest.Mock; conflictsForHarvests: jest.Mock;
conflictsForHoneyBatch: jest.Mock; conflictsForHoneyBatch: jest.Mock;
@@ -193,7 +201,7 @@ describe('WithdrawalEventHandlers', () => {
priority: 'INFO', priority: 'INFO',
category: 'WITHDRAWAL', category: 'WITHDRAWAL',
message: 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', relatedType: 'Colony',
relatedId: 'c1', relatedId: 'c1',
link: '/colonies/c1', link: '/colonies/c1',
@@ -5,12 +5,12 @@ import { isLivingColonyStatus } from '../colonies/colony-status';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { formatDayDe } from '../suggestions/texts/format-day';
import { import {
isInWindow, isInWindow,
withdrawalWindowOf, withdrawalWindowOf,
} from '../treatments/withdrawal-window'; } from '../treatments/withdrawal-window';
import { WithdrawalCheckService } from './withdrawal-check.service'; import { WithdrawalCheckService } from './withdrawal-check.service';
import { withdrawalRangeText } from './withdrawal-text';
const READY_RECIPIENT_PERMISSIONS = [ const READY_RECIPIENT_PERMISSIONS = [
'harvests:create', 'harvests:create',
@@ -123,7 +123,7 @@ export class WithdrawalEventHandlers {
{ {
priority: 'INFO', priority: 'INFO',
category: 'WITHDRAWAL', 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', relatedType: 'Colony',
relatedId: treatment.colonyId, relatedId: treatment.colonyId,
link: `/colonies/${treatment.colonyId}`, link: `/colonies/${treatment.colonyId}`,
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
@@ -152,6 +153,9 @@ describe('Calendar (e2e)', () => {
); );
expect(span).toMatchObject({ expect(span).toMatchObject({
title: `Wartezeit ${colonyCode} · Oxuvar`, title: `Wartezeit ${colonyCode} · Oxuvar`,
context: colonyCode,
product: 'Oxuvar',
detail: withdrawalRangeText('2027-06-20'),
relatedType: 'Colony', relatedType: 'Colony',
relatedId: colonyId, relatedId: colonyId,
}); });
@@ -16,6 +16,8 @@ interface TreatmentBody {
interface WithdrawalStatusBody { interface WithdrawalStatusBody {
colonyId: string; colonyId: string;
activeUntil: string; activeUntil: string;
lastBlockedDay: string;
withdrawalText: string;
product: string; product: string;
} }
@@ -254,6 +256,9 @@ describe('Treatments (e2e)', () => {
{ {
colonyId, colonyId,
activeUntil: '2026-07-15T00:00:00.000Z', 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', product: 'Oxalsäure',
}, },
]); ]);
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
@@ -264,7 +265,7 @@ describe('Withdrawal chain (e2e)', () => {
{ {
code: 'WITHDRAWAL_ACTIVE', code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG', severity: 'WARNUNG',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`, message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
refs: [ refs: [
{ type: 'Colony', id: colony.id }, { type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatmentIds[0] }, { type: 'Treatment', id: treatmentIds[0] },
@@ -283,7 +284,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({ expect(rows[0]).toMatchObject({
priority: 'WARNUNG', priority: 'WARNUNG',
category: 'WITHDRAWAL', 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', relatedType: 'Harvest',
relatedId: harvestId, relatedId: harvestId,
link: `/harvests/${harvestId}`, link: `/harvests/${harvestId}`,
@@ -332,7 +333,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(bottling.warnings).toHaveLength(1); expect(bottling.warnings).toHaveLength(1);
expect(bottling.warnings[0]).toMatchObject({ expect(bottling.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE', 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]); await drainDomainEvents(app, [bottling.id]);
@@ -515,7 +516,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({ expect(rows[0]).toMatchObject({
priority: 'INFO', priority: 'INFO',
category: 'WITHDRAWAL', 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', relatedType: 'Colony',
link: `/colonies/${colony.id}`, link: `/colonies/${colony.id}`,
}); });
@@ -558,7 +559,7 @@ describe('Withdrawal chain (e2e)', () => {
where: { userId: readerId, relatedId: colony.id }, where: { userId: readerId, relatedId: colony.id },
}); });
expect(rows).toHaveLength(1); 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); 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewBottlingBatchPage from "./page"; import NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api"; 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 { fetchProducts } from "@/lib/products/api";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -13,7 +14,11 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push }), useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"), 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/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() })); vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
@@ -39,6 +44,8 @@ describe("NewBottlingBatchPage", () => {
{ id: "hb-1", batchNumber: "2026-01", amount: 40 }, { id: "hb-1", batchNumber: "2026-01", amount: 40 },
] as never); ] as never);
vi.mocked(fetchProducts).mockResolvedValue([] 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(createBottlingBatch).mockReset();
vi.mocked(toast.success).mockReset(); vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset(); vi.mocked(toast.warning).mockReset();
@@ -100,7 +107,7 @@ describe("NewBottlingBatchPage", () => {
{ {
code: "WITHDRAWAL_ACTIVE", code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG", 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" }], refs: [{ type: "Colony", id: "c-3" }],
}, },
], ],
@@ -114,10 +121,45 @@ describe("NewBottlingBatchPage", () => {
expect(toast.warning).toHaveBeenCalledWith( expect(toast.warning).toHaveBeenCalledWith(
"Abfüllung angelegt · 1 Warnung", "Abfüllung angelegt · 1 Warnung",
expect.objectContaining({ expect.objectContaining({
description: expect.stringMatching(/B3.*20\.08\.2026/), description: expect.stringMatching(/B3.*20.08./),
}), }),
); );
expect(toast.success).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/bottling/b-1"); 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 { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Package } from "lucide-react"; import { AlertTriangle, Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch } from "@/lib/bottling/api"; import { createBottlingBatch } from "@/lib/bottling/api";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { toastWarnings } from "@/lib/warnings/toast-warnings"; import { toastWarnings } from "@/lib/warnings/toast-warnings";
@@ -88,6 +89,10 @@ export default function NewBottlingBatchPage() {
queryFn: () => fetchProducts({ status: "ACTIVE" }), queryFn: () => fetchProducts({ status: "ACTIVE" }),
}); });
const { hints: withdrawalHints, failed: withdrawalCheckFailed } = useBottlingWithdrawalHints(
values.honeyBatchId,
);
function set<K extends keyof BottlingFormValues>( function set<K extends keyof BottlingFormValues>(
key: K, key: K,
value: BottlingFormValues[K], value: BottlingFormValues[K],
@@ -197,6 +202,27 @@ export default function NewBottlingBatchPage() {
/> />
)} )}
</Field> </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}> <Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
@@ -59,7 +59,7 @@ describe("CalendarPage withdrawal spans", () => {
const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0]; const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0];
expect(link).toHaveAttribute("href", "/colonies/c-3"); 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); await userEvent.click(day);
expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument(); expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument();
}); });
@@ -102,3 +102,144 @@ describe("CalendarPage withdrawal spans", () => {
expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument(); 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 { RecordSelect } from "@/components/form/record-select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toLocalIsoDate } from "@/lib/dates"; import { toLocalIsoDate } from "@/lib/dates";
import { entryCountText } from "@/lib/text/plural";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; 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 { function eventHref(event: CalendarEvent): string | null {
if (event.relatedType === "Task") return "/tasks"; if (event.relatedType === "Task") return "/tasks";
if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`; if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`;
@@ -82,15 +113,36 @@ function dayHeading(date: Date): string {
return date.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" }); 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 meta = EVENT_META[event.type];
const Icon = meta.icon; const Icon = meta.icon;
const href = eventHref(event); const href = eventHref(event);
const content = ( const context = eventContext(event);
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm", meta.tone)}> 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 /> <Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{meta.label}</span> <span className="shrink-0 text-xs font-medium text-muted-foreground">{meta.label}</span>
<span className="truncate">{event.title}</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> </span>
); );
return href ? ( return href ? (
@@ -106,10 +158,13 @@ function DayEvents({
date, date,
events, events,
isToday, isToday,
isContinuation = () => false,
}: { }: {
date: Date; date: Date;
events: CalendarEvent[]; events: CalendarEvent[];
isToday: boolean; isToday: boolean;
/** True for a withdrawal span that already showed in full on an earlier day of the list. */
isContinuation?: (event: CalendarEvent) => boolean;
}) { }) {
return ( return (
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
@@ -127,7 +182,7 @@ function DayEvents({
<ul className="flex flex-col gap-1.5"> <ul className="flex flex-col gap-1.5">
{events.map((event) => ( {events.map((event) => (
<li key={event.id}> <li key={event.id}>
<EventRow event={event} /> <EventRow event={event} isContinuation={isContinuation(event)} />
</li> </li>
))} ))}
</ul> </ul>
@@ -180,6 +235,14 @@ export default function CalendarPage() {
(day) => (day) =>
day.getMonth() === month.getMonth() && (eventsByDay.get(toLocalIsoDate(day))?.length ?? 0) > 0, 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); const selectedDay = gridDays.find((day) => toLocalIsoDate(day) === selectedKey);
function goToMonth(offset: number) { function goToMonth(offset: number) {
@@ -258,6 +321,9 @@ export default function CalendarPage() {
date={day} date={day}
events={eventsByDay.get(toLocalIsoDate(day)) ?? []} events={eventsByDay.get(toLocalIsoDate(day)) ?? []}
isToday={toLocalIsoDate(day) === todayKey} 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" type="button"
onClick={() => setSelectedKey(key)} onClick={() => setSelectedKey(key)}
aria-pressed={isSelected} aria-pressed={isSelected}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${dayEvents.length} Einträge`} aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
className={cn( className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0", "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", isCurrentMonth ? "bg-card" : "bg-muted/40",
@@ -317,13 +383,16 @@ export default function CalendarPage() {
return ( return (
<span <span
key={event.id} key={event.id}
title={eventFullText(event)}
className={cn( className={cn(
"flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs", "flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs",
meta.tone, meta.tone,
)} )}
> >
<Icon className="size-3 shrink-0" aria-hidden /> <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> </span>
); );
})} })}
@@ -121,6 +121,7 @@ describe("ColonyActions – colonies that need attention", () => {
for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) { for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) {
expect(screen.getByRole("button", { name })).toBeEnabled(); expect(screen.getByRole("button", { name })).toBeEnabled();
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
} }
expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument();
}); });
@@ -95,7 +95,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="outline" disabled={!isLivingColony(colony)}> <Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Umsetzen Umsetzen
</Button> </Button>
} }
@@ -216,7 +216,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="outline" disabled={!isLivingColony(colony)}> <Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Ableger bilden Ableger bilden
</Button> </Button>
} }
@@ -325,7 +325,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
render={ 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"} {currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"}
</Button> </Button>
} }
@@ -398,7 +398,7 @@ function MergeDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="outline" disabled={!isLivingColony(colony)}> <Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Vereinigen Vereinigen
</Button> </Button>
} }
@@ -461,6 +461,7 @@ function DissolveButton({ colony }: { colony: Colony }) {
return ( return (
<ConfirmDialog <ConfirmDialog
triggerLabel="Auflösen" triggerLabel="Auflösen"
triggerClassName="min-h-11"
triggerDisabled={!isLivingColony(colony) || mutation.isPending} triggerDisabled={!isLivingColony(colony) || mutation.isPending}
title="Volk auflösen?" 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.`} 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card"; import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api"; import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import type { Colony } from "@/lib/colonies/types"; import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() })); 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"); 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 [notes, setNotes] = useState(colony.notes ?? "");
const { data: apiaries } = useQuery({ const apiariesQuery = useQuery({
queryKey: ["apiaries"], queryKey: ["apiaries"],
queryFn: () => fetchApiaries(), 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({ const updateMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -136,15 +144,21 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="colony-apiary">Bienenstand</Label> <Label htmlFor="colony-apiary">Bienenstand</Label>
<RecordSelect {apiaryListUnavailable ? (
id="colony-apiary" <p id="colony-apiary" className="min-h-11 content-center text-sm">
value={apiaryId} {cachedApiaryName ?? "Bienenstand derzeit nicht ladbar"}
// A colony always stands somewhere: there is no "none" to pick. </p>
onChange={(value) => setApiaryId(value || apiaryId)} ) : (
items={apiaries} <RecordSelect
getLabel={(apiary) => apiary.name} id="colony-apiary"
placeholder="Bienenstand wählen" value={apiaryId}
/> // A colony always stands somewhere: there is no "none" to pick.
onChange={(value) => setApiaryId(value || apiaryId)}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
festgehalten. festgehalten.
@@ -57,7 +57,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
{(feedings) => {(feedings) =>
feedings.map((feeding) => ( feedings.map((feeding) => (
<div key={feeding.id} className="rounded-md border p-3"> <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"> <p className="font-medium">
{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "} {new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
{FEED_TYPE_LABELS[feeding.feedType]} {FEED_TYPE_LABELS[feeding.feedType]}
@@ -44,7 +44,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{(inspections) => {(inspections) =>
inspections.map((inspection) => ( inspections.map((inspection) => (
<div key={inspection.id} className="rounded-md border p-3"> <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"> <p className="font-medium">
{new Date(inspection.date).toLocaleDateString("de-DE")} {new Date(inspection.date).toLocaleDateString("de-DE")}
</p> </p>
@@ -46,7 +46,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
{(checks) => {(checks) =>
checks.map((check) => ( checks.map((check) => (
<div key={check.id} className="rounded-md border p-3"> <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> <p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
<SwarmControlEditDialog check={check} /> <SwarmControlEditDialog check={check} />
</div> </div>
@@ -44,7 +44,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{(treatments) => {(treatments) =>
treatments.map((treatment) => ( treatments.map((treatment) => (
<div key={treatment.id} className="rounded-md border p-3"> <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"> <p className="font-medium">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product} {new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p> </p>
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa"; import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api"; 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(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).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"; import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 300; const CHART_WIDTH = 300;
const CHART_HEIGHT = 120; const CHART_HEIGHT = 136;
const PAD_X = 12; const PAD_X = 12;
const PAD_TOP = 12; const PAD_TOP = 12;
const PAD_BOTTOM = 12; const PAD_BOTTOM = 28;
const MARKER_RADIUS = 4; const MARKER_RADIUS = 4;
const LABEL_GAP = 4; const LABEL_GAP = 4;
const AXIS_LABEL_SIZE = 10;
const MAX_BAND_MONTHS = 24; const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -74,6 +75,13 @@ function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
.sort((left, right) => left.time - right.time); .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 { function nextMonthStart(start: number): number {
const date = new Date(start); const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
@@ -194,57 +202,118 @@ function VarroaTrendChart({
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range); const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
const monthly = monthLimitsText(limits, range); const monthly = monthLimitsText(limits, range);
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
const labelY = CHART_HEIGHT - LABEL_GAP;
return ( return (
<svg <div className="flex flex-col gap-2">
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`} <svg
className="w-full text-primary" viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
role="img" className="w-full text-primary"
aria-labelledby={titleId} role="img"
aria-describedby={descId} aria-labelledby={titleId}
> aria-describedby={descId}
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title> >
<desc id={descId}> <title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ <desc id={descId}>
monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
}`} monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
</desc> }`}
{redPath || yellowPath ? ( </desc>
<g className="text-muted-foreground" fill="none" stroke="currentColor"> {redPath || yellowPath ? (
{yellowPath ? ( <g className="text-muted-foreground" fill="none" stroke="currentColor">
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" /> {yellowPath ? (
) : null} <path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
{redPath ? ( ) : null}
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" /> {redPath ? (
) : null} <path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
</g> ) : null}
) : null} </g>
{lastLimit ? ( ) : null}
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
className="fill-muted-foreground"
>
{`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 <text
x={CHART_WIDTH - PAD_X} x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP} y={labelY}
textAnchor="end" textAnchor="end"
fontSize={10} fontSize={AXIS_LABEL_SIZE}
className="fill-muted-foreground" className="fill-muted-foreground"
> >
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`} {formatDate(last.time)}
</text> </text>
) : null} <polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} /> {points.map((p) => (
{points.map((p) => ( <circle
<circle key={p.id}
key={p.id} cx={xOf(p.time)}
cx={xOf(p.time)} cy={yOf(p.rate)}
cy={yOf(p.rate)} r={MARKER_RADIUS}
r={MARKER_RADIUS} fill="currentColor"
fill="currentColor" className="stroke-background"
className="stroke-background" strokeWidth={2}
strokeWidth={2} />
/> ))}
))} </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> </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>
);
}
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) { function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
const note = ratingNote(measurement.rating); const note = ratingNote(measurement.rating);
return ( return (
@@ -296,9 +365,9 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
{(measurements) => ( {(measurements) => (
<> <>
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} /> <VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
{measurements.map((measurement) => ( {newestFirst(measurements).map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3"> <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"> <p className="font-medium">
{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "} {new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
{VARROA_METHOD_LABELS[measurement.method]} {VARROA_METHOD_LABELS[measurement.method]}
@@ -39,10 +39,33 @@ describe("EditTriggerButton", () => {
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten"); 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")); act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />); 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"; "use client";
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import { Pencil } from "lucide-react"; import { Pencil, WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSessionMode } from "@/lib/auth/auth-context"; import { useSessionMode } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity"; 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 * 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 * 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 * 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({ export function EditTriggerButton({
disabled, disabled,
@@ -21,17 +24,30 @@ export function EditTriggerButton({
const connectivity = useConnectivity(); const connectivity = useConnectivity();
const sessionMode = useSessionMode(); const sessionMode = useSessionMode();
const needsNetwork = connectivity === "offline" || sessionMode === "offline"; const needsNetwork = connectivity === "offline" || sessionMode === "offline";
return ( const button = (
<Button <Button
variant="ghost" variant="ghost"
{...props} {...props}
disabled={disabled || needsNetwork} 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)}
> >
<Pencil className="size-4" aria-hidden /> {needsNetwork ? (
{needsNetwork ? "Bearbeiten braucht Netz" : "Bearbeiten"} <WifiOff className="size-4" aria-hidden />
) : (
<Pencil className="size-4" aria-hidden />
)}
Bearbeiten
</Button> </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 { interface DialogSubmitBarProps {
@@ -1,12 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { act, render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import DashboardPage from "./page"; import DashboardPage from "./page";
import { fetchAutopilotToday } from "@/lib/autopilot/api"; import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures"; import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { fetchTasks } from "@/lib/tasks/api"; import { fetchTasks } from "@/lib/tasks/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Task } from "@/lib/tasks/types"; import type { Task } from "@/lib/tasks/types";
vi.mock("@/lib/auth/auth-context", () => ({ vi.mock("@/lib/auth/auth-context", () => ({
@@ -26,6 +28,7 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()), ...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(), fetchAutopilotToday: vi.fn(),
})); }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
})); }));
@@ -61,6 +64,10 @@ function renderPage() {
} }
describe("DashboardPage – Heute", () => { describe("DashboardPage – Heute", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => { beforeEach(() => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({ vi.mocked(fetchDashboardSummary).mockResolvedValue({
apiariesActive: 1, apiariesActive: 1,
@@ -172,4 +179,77 @@ describe("DashboardPage – Heute", () => {
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary"); expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: "Runde starten" })).not.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 { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
import { TodayPanel } from "@/components/today/today-panel"; import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null { function alertHref(alert: DashboardAlert): string | null {
@@ -89,14 +90,16 @@ const ALERT_META: Record<
export default function DashboardPage() { export default function DashboardPage() {
const { user } = useAuth(); const { user } = useAuth();
const [now] = useState(() => new Date()); const [now] = useState(() => new Date());
const { data: summary } = useQuery({ const summaryQuery = useQuery({
queryKey: ["dashboard", "summary"], queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary, queryFn: fetchDashboardSummary,
}); });
const { data: alerts, isLoading: alertsLoading } = useQuery({ const { data: summary } = summaryQuery;
const alertsQuery = useQuery({
queryKey: ["dashboard", "alerts"], queryKey: ["dashboard", "alerts"],
queryFn: fetchDashboardAlerts, queryFn: fetchDashboardAlerts,
}); });
const { data: alerts, isLoading: alertsLoading } = alertsQuery;
const tasksQuery = useQuery({ const tasksQuery = useQuery({
queryKey: ["tasks", "dashboard"], queryKey: ["tasks", "dashboard"],
queryFn: () => fetchTasks({}), queryFn: () => fetchTasks({}),
@@ -119,6 +122,14 @@ export default function DashboardPage() {
(autopilotError instanceof ApiError && autopilotError.status === 403)); (autopilotError instanceof ApiError && autopilotError.status === 403));
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK"); const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null; 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 firstName = user?.name.trim().split(/\s+/)[0];
const tiles: StatTile[] = summary const tiles: StatTile[] = summary
@@ -161,6 +172,7 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
</header> </header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
{autopilotError && !isAutopilotSilent ? ( {autopilotError && !isAutopilotSilent ? (
<div <div
@@ -211,7 +223,7 @@ export default function DashboardPage() {
</span> </span>
{href ? ( {href ? (
<Link <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} href={href}
> >
Ansehen Ansehen
@@ -100,7 +100,7 @@ describe("NewHarvestPage", () => {
it("checks the withdrawal period at the harvest date and again when it changes", async () => { it("checks the withdrawal period at the harvest date and again when it changes", async () => {
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) => vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) =>
at === "2026-07-01" 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(); renderPage();
@@ -114,7 +114,7 @@ describe("NewHarvestPage", () => {
await userEvent.clear(date); await userEvent.clear(date);
await userEvent.type(date, "2026-07-01"); 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( expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith(
["colony-1", "colony-2"], ["colony-1", "colony-2"],
"2026-07-01", "2026-07-01",
@@ -153,7 +153,7 @@ describe("NewHarvestPage", () => {
{ {
code: "WITHDRAWAL_ACTIVE", code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG", 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: [], refs: [],
}, },
], ],
@@ -165,7 +165,7 @@ describe("NewHarvestPage", () => {
expect(toast.warning).toHaveBeenCalledWith( expect(toast.warning).toHaveBeenCalledWith(
"Ernte gespeichert · 1 Warnung", "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(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/harvests/h-1"); expect(push).toHaveBeenCalledWith("/harvests/h-1");
@@ -270,10 +270,8 @@ export default function NewHarvestPage() {
className="size-3.5 shrink-0" className="size-3.5 shrink-0"
aria-hidden aria-hidden
/> />
Wartezeit ({withdrawal.product}) bis{" "} Wartezeit nach {withdrawal.product}{" "}
{new Date( {withdrawal.withdrawalText}
withdrawal.activeUntil,
).toLocaleDateString("de-DE")}
</p> </p>
) : null} ) : null}
</div> </div>
+46 -2
View File
@@ -7,12 +7,14 @@ import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout"; import AppLayout from "./layout";
const pushMock = vi.fn(); const pushMock = vi.fn();
let pathname = "/inspections/new";
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: pushMock }), useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => "/inspections/new", usePathname: () => pathname,
})); }));
let sessionMode: "online" | "offline" = "online"; let sessionMode: "online" | "offline" = "online";
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({ vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ useAuth: () => ({
@@ -22,7 +24,7 @@ vi.mock("@/lib/auth/auth-context", () => ({
name: "Test Imker", name: "Test Imker",
roleKey: "admin", roleKey: "admin",
roleName: "Admin", roleName: "Admin",
permissions: [], permissions,
mfaEnabled: false, mfaEnabled: false,
hasAvatar: false, hasAvatar: false,
}, },
@@ -91,9 +93,51 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
describe("AppLayout", () => { describe("AppLayout", () => {
beforeEach(() => { beforeEach(() => {
sessionMode = "online"; sessionMode = "online";
pathname = "/inspections/new";
permissions = [];
pushMock.mockClear(); 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", () => { it("shows the offline band for the signed-in user right below the header", () => {
render( render(
<AppLayout> <AppLayout>
+10 -3
View File
@@ -2,7 +2,7 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Hexagon } from "lucide-react"; import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
@@ -21,11 +21,12 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav"; import { BottomNav } from "./bottom-nav";
import { QuickAddSheet } from "./quick-add-sheet"; import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition"; import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user, isLoading, sessionMode } = useAuth(); const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false); const [isLogoutOpen, setIsLogoutOpen] = useState(false);
// Focus lands here when the offline queue sheet closes and nothing else is left. // 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; 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 // The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture // scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick. // `position: sticky` and the StickySubmit save bar would never stick.
@@ -112,7 +119,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div <div
ref={pageContentRef} ref={pageContentRef}
tabIndex={-1} 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> <PageTransition>{children}</PageTransition>
</div> </div>
@@ -18,6 +18,14 @@ import { QUICK_ACTIONS } from "./quick-actions";
// quick action anyway - and the header button hides with it for consistency. // quick action anyway - and the header button hides with it for consistency.
const FORM_ROUTE = /\/(new|edit)(\/|$)/; 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 { interface QuickAddSheetProps {
user: Pick<AuthUser, "permissions">; user: Pick<AuthUser, "permissions">;
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */ /** "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), user.permissions.includes(action.permission),
); );
if (FORM_ROUTE.test(pathname) || actions.length === 0) { if (!hasQuickAddButton(pathname, user.permissions)) {
return null; 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) { async function card(title: string) {
const heading = await screen.findByText(title); 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. // 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 }); 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"; } from "@/lib/automation/api";
import { import {
RULE_DESCRIPTIONS, RULE_DESCRIPTIONS,
RULE_LABELS,
SIMPLE_RULE_NOTIFY_DEFAULTS, SIMPLE_RULE_NOTIFY_DEFAULTS,
issuesByField, issuesByField,
} from "@/lib/automation/rule-configs"; } 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 { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format"; import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip"; import { ToggleChip } from "@/components/form/toggle-chip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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"; import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [ const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
@@ -95,7 +94,15 @@ function ruleNotify(rule: AutomationRule): boolean {
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false); : (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 queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? ""); const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [notify, setNotify] = useState(ruleNotify(rule)); const [notify, setNotify] = useState(ruleNotify(rule));
@@ -162,14 +169,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
} }
return ( return (
<Card> <RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<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">
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p> <p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
{numberFormatRule ? ( {numberFormatRule ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -227,8 +227,9 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label> <Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input <Textarea
id={`template-${rule.id}`} id={`template-${rule.id}`}
rows={3}
value={actionTemplate} value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)} onChange={(event) => setActionTemplate(event.target.value)}
/> />
@@ -278,23 +279,32 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</p> </p>
))} ))}
{!numberFormatRule ? ( {!numberFormatRule ? (
<ToggleChip label="Zusätzlich melden" checked={notify} <div className="flex flex-col gap-1">
onChange={(checked) => { <ToggleChip
clearIssues("notify"); label="Zusätzlich melden"
setNotify(checked); 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} ) : null}
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="min-h-11"
disabled={mutation.isPending} disabled={mutation.isPending}
onClick={() => mutation.mutate({ active: !rule.active })} onClick={() => mutation.mutate({ active: !rule.active })}
> >
{rule.active ? "Deaktivieren" : "Aktivieren"} {rule.active ? "Deaktivieren" : "Aktivieren"}
</Button> </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"} {mutation.isPending ? "Speichert…" : "Speichern"}
</Button> </Button>
</div> </div>
@@ -310,12 +320,12 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
onConfirm={() => resetMutation.mutate()} onConfirm={() => resetMutation.mutate()}
/> />
) : null} ) : null}
</CardContent> </RuleCardShell>
</Card>
); );
} }
export function AutomationRulesTab() { export function AutomationRulesTab() {
const [openRuleId, setOpenRuleId] = useState<string | null>(null);
const { data: rules, isLoading, isError } = useQuery({ const { data: rules, isLoading, isError } = useQuery({
queryKey: ["automation-rules"], queryKey: ["automation-rules"],
queryFn: fetchAutomationRules, 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"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) => {rules?.map((rule) =>
hasRuleForm(rule.triggerType) ? ( {
<SpecialRuleCard key={rule.id} rule={rule} /> const open = openRuleId === rule.id;
) : ( const onToggle = () => setOpenRuleId(open ? null : rule.id);
<RuleCard key={rule.id} rule={rule} /> return hasRuleForm(rule.triggerType) ? (
), <SpecialRuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
) : (
<RuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
);
},
)} )}
</div> </div>
</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) { async function card(title: string) {
const heading = await screen.findByText(title); 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. // 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 client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrap = (current: AutomationRule) => ( const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<SpecialRuleCard rule={current} /> <SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider> </QueryClientProvider>
); );
const first = rule("PHENOLOGY", null); const first = rule("PHENOLOGY", null);
@@ -299,7 +301,7 @@ describe("fix round 2", () => {
vi.mocked(updateAutomationRule).mockResolvedValue(phenology); vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
const wrap = (current: AutomationRule) => ( const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<SpecialRuleCard rule={current} /> <SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider> </QueryClientProvider>
); );
const view = render(wrap(phenology)); const view = render(wrap(phenology));
@@ -11,7 +11,7 @@ import { AutomationTriggerType } from "@/lib/automation/types";
import { RuleDraft } from "./use-rule-draft"; import { RuleDraft } from "./use-rule-draft";
export const NOTIFY_LABEL = "Zusätzlich melden"; 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. */ /** The switch every rule with a notification has. */
export function NotifyToggle({ export function NotifyToggle({
@@ -166,7 +166,7 @@ export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormSh
</ul> </ul>
) : null} ) : null}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={pending}> <Button type="submit" className="min-h-11" disabled={pending}>
{pending ? "Speichert…" : "Speichern"} {pending ? "Speichert…" : "Speichern"}
</Button> </Button>
</div> </div>
@@ -4,12 +4,11 @@ import { ComponentType, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateAutomationRule } from "@/lib/automation/api"; 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 { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { ChainForm } from "./rule-forms/chain-form";
import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form"; import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form";
import { NotifyForm } from "./rule-forms/notify-form"; import { NotifyForm } from "./rule-forms/notify-form";
@@ -44,7 +43,15 @@ export function hasRuleForm(trigger: AutomationTriggerType): boolean {
return trigger in RULE_FORMS; 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 queryClient = useQueryClient();
const [issues, setIssues] = useState<Record<string, string>>({}); const [issues, setIssues] = useState<Record<string, string>>({});
const Form = RULE_FORMS[rule.triggerType] as ComponentType<RuleFormProps>; const Form = RULE_FORMS[rule.triggerType] as ComponentType<RuleFormProps>;
@@ -67,14 +74,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
}); });
return ( return (
<Card> <RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<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">
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p> <p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
<Form <Form
key={JSON.stringify(rule.config)} key={JSON.stringify(rule.config)}
@@ -87,6 +87,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="min-h-11"
disabled={mutation.isPending} disabled={mutation.isPending}
onClick={() => { onClick={() => {
setIssues({}); setIssues({});
@@ -96,7 +97,6 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
{rule.active ? "Deaktivieren" : "Aktivieren"} {rule.active ? "Deaktivieren" : "Aktivieren"}
</Button> </Button>
</div> </div>
</CardContent> </RuleCardShell>
</Card>
); );
} }
@@ -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 { 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 userEvent from "@testing-library/user-event";
import { toast } from "sonner"; import { toast } from "sonner";
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures"; import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { import {
acceptSuggestion, acceptSuggestion,
bulkAcceptSuggestions, bulkAcceptSuggestions,
@@ -116,6 +117,10 @@ Object.defineProperty(window, "matchMedia", {
HTMLElement.prototype.setPointerCapture = () => undefined; HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined; HTMLElement.prototype.releasePointerCapture = () => undefined;
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
toast.dismiss(); toast.dismiss();
@@ -478,4 +483,49 @@ describe("SuggestionsPage", () => {
expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument(); 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 { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { OnlineOnlyHint, StandNote } from "@/components/offline/stand-note";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { SuggestionGroup } from "@/components/suggestions/suggestion-group"; import { SuggestionGroup } from "@/components/suggestions/suggestion-group";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context"; 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 { newIdempotencyKey } from "@/lib/offline/idempotency";
import { startRound } from "@/lib/rounds/round-state"; import { startRound } from "@/lib/rounds/round-state";
import { import {
@@ -35,8 +38,6 @@ const UNDO_TOAST_MS = 8000;
/** The API takes 1 to 50 ids per bulk call. */ /** The API takes 1 to 50 ids per bulk call. */
const BULK_LIMIT = 50; const BULK_LIMIT = 50;
const TASKS_HREF = "/tasks"; 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. * 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 }) => { onSuccess: (_result, { suggestion }) => {
requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id }); requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id });
refresh(); refresh();
toast.success("Aufgabe angelegt", { toast.success("Aufgabe angelegt", { action: viewTasks });
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
}, },
onMutate: ({ suggestion }) => markPending([suggestion.id]), onMutate: ({ suggestion }) => markPending([suggestion.id]),
onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]), onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]),
@@ -227,10 +225,7 @@ export default function SuggestionsPage() {
onSuccess: (count) => { onSuccess: (count) => {
requestFocus({ target: "list", gone: null }); requestFocus({ target: "list", gone: null });
refresh(); refresh();
toast.success(taskCountText(count), { toast.success(taskCountText(count), { action: viewTasks });
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
}, },
onMutate: (ids) => markPending(ids), onMutate: (ids) => markPending(ids),
onSettled: (_result, _error, ids) => unmarkPending(ids), onSettled: (_result, _error, ids) => unmarkPending(ids),
@@ -258,7 +253,6 @@ export default function SuggestionsPage() {
toast("Verworfen", { toast("Verworfen", {
duration: UNDO_TOAST_MS, duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) }, action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) },
classNames: { actionButton: TOAST_ACTION_CLASS },
}); });
}, },
onMutate: (suggestion) => markPending([suggestion.id]), onMutate: (suggestion) => markPending([suggestion.id]),
@@ -293,6 +287,11 @@ export default function SuggestionsPage() {
> >
{countText(loaded, total)} {countText(loaded, total)}
</p> </p>
<StandNote
updatedAt={openQuery.dataUpdatedAt}
isStale={openQuery.isError && openQuery.data !== undefined}
className="-mt-3"
/>
<QueryState <QueryState
query={{ query={{
@@ -349,7 +348,7 @@ export default function SuggestionsPage() {
> >
<ChevronDown <ChevronDown
aria-hidden aria-hidden
className={showDismissed ? "rotate-180 transition-transform" : "transition-transform"} className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
/> />
Verworfene (30 Tage) Verworfene (30 Tage)
</Button> </Button>
@@ -403,6 +402,7 @@ function DismissedRow({
canReopen: boolean; canReopen: boolean;
onReopen: () => void; onReopen: () => void;
}) { }) {
const isOffline = useConnectivity() === "offline";
const Icon = INTENT_ICONS[suggestion.intent]; const Icon = INTENT_ICONS[suggestion.intent];
const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · "); const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · ");
return ( return (
@@ -417,10 +417,13 @@ function DismissedRow({
</p> </p>
</div> </div>
{canReopen ? ( {canReopen ? (
<Button size="touch" variant="outline" disabled={busy} onClick={onReopen}> <div className="flex flex-col items-start gap-1">
<RotateCcw aria-hidden /> <Button size="touch" variant="outline" disabled={busy || isOffline} onClick={onReopen}>
Wieder öffnen <RotateCcw aria-hidden />
</Button> Wieder öffnen
</Button>
<OnlineOnlyHint />
</div>
) : null} ) : null}
</li> </li>
); );
+9
View File
@@ -197,6 +197,15 @@
--header-clearance: 4.5rem; --header-clearance: 4.5rem;
/* StickySubmit with its 48px button (~4.6rem) plus breathing room. */ /* StickySubmit with its 48px button (~4.6rem) plus breathing room. */
--sticky-submit-clearance: 5.5rem; --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 { @layer base {
+1 -1
View File
@@ -43,7 +43,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers> <Providers>{children}</Providers>
<Toaster position="top-right" richColors /> <Toaster richColors />
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
</body> </body>
</html> </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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NextStepsCard } from "./next-steps-card"; 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 { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures";
import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api"; import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { toast } from "sonner"; import { toast } from "sonner";
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
@@ -49,6 +50,10 @@ const TASK_STEP = stepFixture({
}); });
describe("NextStepsCard", () => { describe("NextStepsCard", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => { beforeEach(() => {
vi.mocked(fetchColonyNextSteps).mockReset(); vi.mocked(fetchColonyNextSteps).mockReset();
vi.mocked(acceptSuggestion).mockReset(); vi.mocked(acceptSuggestion).mockReset();
@@ -233,4 +238,47 @@ describe("NextStepsCard", () => {
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement()); 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 Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react"; 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 { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { DueChip } from "@/components/task-status"; import { DueChip } from "@/components/task-status";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
@@ -51,6 +52,11 @@ export function StepActions({
}: StepActionsProps) { }: StepActionsProps) {
const { action } = step; const { action } = step;
const variant = primary ? "default" : "outline"; 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 ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{action.type === "CAPTURE" ? ( {action.type === "CAPTURE" ? (
@@ -66,23 +72,42 @@ export function StepActions({
</Link> </Link>
) : null} ) : null}
{action.type === "ACCEPT" && onAccept && canAccept ? ( {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 /> <Check aria-hidden />
Übernehmen Übernehmen
</Button> </Button>
) : null} ) : null}
{action.type === "CLEANUP" && onCleanup && canDismiss ? ( {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 /> <Eraser aria-hidden />
Aufräumen Aufräumen
</Button> </Button>
) : null} ) : null}
{step.dismissible && onDismiss && canDismiss ? ( {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 /> <X aria-hidden />
Verwerfen Verwerfen
</Button> </Button>
) : null} ) : null}
{hasAccept || hasCleanup || hasDismiss ? <OnlineOnlyHint id={hintId} className="basis-full" /> : null}
</div> </div>
); );
} }
@@ -180,7 +205,8 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
}); });
const stepActions = useStepActions(NEXT_STEPS_HEADING_ID); 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. // Offline the colony page still works; a missing hint is no news.
if (isNetworkError(query.error)) return null; if (isNetworkError(query.error)) return null;
return ( return (
@@ -202,6 +228,7 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
return ( return (
<> <>
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError} />
<NextStepsView <NextStepsView
steps={query.data.steps} steps={query.data.steps}
withdrawalUntil={query.data.withdrawalUntil} withdrawalUntil={query.data.withdrawalUntil}
@@ -12,8 +12,6 @@ import {
} from "@/lib/suggestions/api"; } from "@/lib/suggestions/api";
import { suggestionErrorText } from "@/lib/suggestions/errors"; 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; const UNDO_TOAST_MS = 8000;
/** Everything a decision can change on screen. */ /** Everything a decision can change on screen. */
const REFRESHED_KEYS = [ const REFRESHED_KEYS = [
@@ -128,7 +126,6 @@ export function useStepActions(headingId: string) {
toast("Verworfen", { toast("Verworfen", {
duration: UNDO_TOAST_MS, duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) }, action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) },
classNames: { actionButton: TOAST_ACTION_CLASS },
}), }),
onError: (error) => toast.error(suggestionErrorText(error)), onError: (error) => toast.error(suggestionErrorText(error)),
onSettled: async (_result, _error, step) => { onSettled: async (_result, _error, step) => {
@@ -69,6 +69,11 @@ describe("DocumentUploadCard", () => {
vi.mocked(toast.error).mockReset(); 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 () => { it("re-encodes images before upload, PDFs unchanged", async () => {
const { container } = renderCard(); const { container } = renderCard();
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" }); const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
@@ -208,6 +208,7 @@ export function DocumentUploadCard({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()} 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); setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 }); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand(); renderBand();
expect(await screen.findByRole("status")).toHaveTextContent( const status = await screen.findByRole("status");
"Offline – Eingaben werden gespeichert und später gesendet", 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 () => { 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 /> <CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0 {queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}` ? `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> </p>
) : null} ) : null}
{!isOffline && queue.own > 0 ? ( {!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 { 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 userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CleanupSheet } from "./cleanup-sheet"; import { CleanupSheet } from "./cleanup-sheet";
@@ -10,6 +10,7 @@ import { fetchQueen } from "@/lib/queens/api";
import { fetchSuggestions } from "@/lib/suggestions/api"; import { fetchSuggestions } from "@/lib/suggestions/api";
import { fetchTask } from "@/lib/tasks/api"; import { fetchTask } from "@/lib/tasks/api";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Colony } from "@/lib/colonies/types"; import type { Colony } from "@/lib/colonies/types";
import type { Queen } from "@/lib/queens/types"; import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types"; import type { Task } from "@/lib/tasks/types";
@@ -61,6 +62,14 @@ function renderSheet(onClose = vi.fn(), onDone = vi.fn()) {
return { onClose, onDone }; 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", () => { describe("CleanupSheet", () => {
beforeEach(() => { beforeEach(() => {
permissions.clear(); 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 { onClose } = renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ }); 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/ }); 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" })); await userEvent.click(within(second).getByRole("radio", { name: "Behalten" }));
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ }); const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
expect(within(queen).getByRole("radio", { name: "verstorben" })).toHaveAttribute("aria-checked", "true"); 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"), 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 userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled()); await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -148,6 +164,7 @@ describe("CleanupSheet", () => {
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ }); await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument(); expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" })); await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled()); await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -182,6 +199,7 @@ describe("CleanupSheet", () => {
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" })); await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument(); expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
}); });
@@ -193,6 +211,7 @@ describe("CleanupSheet", () => {
message: "Nur Aufgaben und Königin aus dem Vorschlag.", message: "Nur Aufgaben und Königin aus dem Vorschlag.",
}), }),
); );
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" })); await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() => await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Nur Aufgaben und Königin aus dem Vorschlag."), 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.", {})); vi.mocked(applyCleanup).mockRejectedValue(new ApiError(409, "Das Volk ist wieder aktiv.", {}));
const { toast } = await import("sonner"); const { toast } = await import("sonner");
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" })); await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Das Volk ist wieder aktiv.")); await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Das Volk ist wieder aktiv."));
expect(onClose).not.toHaveBeenCalled(); 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 { Eraser } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group"; import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Sheet, Sheet,
@@ -28,8 +29,8 @@ import { ApiError } from "@/lib/auth/api-client";
type QueenChoice = "DECEASED" | "SOLD" | "KEEP"; type QueenChoice = "DECEASED" | "SOLD" | "KEEP";
const TASK_OPTIONS: ChoiceOption<CleanupOutcome>[] = [ const TASK_OPTIONS: ChoiceOption<CleanupOutcome | null>[] = [
{ value: "CANCEL", label: "Abbrechen" }, { value: "CANCEL", label: "Aufgabe abbrechen" },
{ value: "DONE", label: "Erledigt" }, { value: "DONE", label: "Erledigt" },
{ value: "KEEP", label: "Behalten" }, { value: "KEEP", label: "Behalten" },
]; ];
@@ -122,7 +123,7 @@ export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: Cleanu
showCloseButton={false} showCloseButton={false}
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)" 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 ? ( {suggestionId !== null ? (
<CleanupForm <CleanupForm
key={suggestionId} key={suggestionId}
@@ -143,9 +144,11 @@ interface CleanupFormProps extends Omit<CleanupSheetProps, "suggestionId"> {
function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) { function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canComplete = hasPermission("tasks:complete"); const canComplete = hasPermission("tasks:complete");
const canChangeQueen = hasPermission("queens:update"); 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 [outcomes, setOutcomes] = useState<Readonly<Record<string, CleanupOutcome>>>({});
const [queenOverride, setQueenOverride] = useState<QueenChoice | null>(null); const [queenOverride, setQueenOverride] = useState<QueenChoice | null>(null);
// One key per set of choices: a retry after a lost answer is not applied twice. // 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 goneCount = evidence.filter(({ task }) => task === null).length;
const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError)); const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError));
const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status); const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status);
const hasAllChoices = taskRows.every(({ id }) => outcomes[id] !== undefined);
const isReady = const isReady =
suggestion !== null && suggestion !== null &&
hasAllChoices &&
(taskIds.length === 0 || tasks.isSuccess) && (taskIds.length === 0 || tasks.isSuccess) &&
(queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess))); (queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess)));
@@ -214,7 +219,10 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
} }
function submit() { function submit() {
mutation.mutate({ 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. // „unverändert“ sends nothing: the queen keeps her status.
...(queenId !== null && queen.data && queenChoice !== "KEEP" ...(queenId !== null && queen.data && queenChoice !== "KEEP"
? { queen: { id: queenId, status: queenChoice } } ? { queen: { id: queenId, status: queenChoice } }
@@ -231,7 +239,7 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (isReady) submit(); if (isReady && !isOffline) submit();
}} }}
className="flex flex-col gap-4 px-4 pb-4" className="flex flex-col gap-4 px-4 pb-4"
> >
@@ -285,13 +293,18 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
</p> </p>
<ChoiceGroup <ChoiceGroup
options={taskOptions} options={taskOptions}
value={outcomes[id] ?? "CANCEL"} value={outcomes[id] ?? null}
onChange={(outcome) => changeOutcome(id, outcome)} onChange={(outcome) => {
if (outcome !== null) changeOutcome(id, outcome);
}}
labelledBy={`cleanup-task-${id}`} labelledBy={`cleanup-task-${id}`}
className="grid-cols-3" className="grid-cols-3"
/> />
</div> </div>
))} ))}
{!hasAllChoices ? (
<p className="text-sm text-muted-foreground">Bitte für jede Aufgabe etwas wählen.</p>
) : null}
{!canComplete ? ( {!canComplete ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
„Erledigt“ braucht das Recht „Aufgaben abschließen“. „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. Aufräumen ist erst möglich, wenn alles geladen ist.
</p> </p>
) : null} ) : null}
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse"> <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 /> <Eraser aria-hidden />
{mutation.isPending ? "Wird gespeichert…" : "Aufräumen"} {mutation.isPending ? "Wird gespeichert…" : "Aufräumen"}
</Button> </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"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -44,10 +45,11 @@ export function DueDateSheet(props: DueDateSheetProps) {
function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) { function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) {
const [date, setDate] = useState(initialDate); const [date, setDate] = useState(initialDate);
const { isOffline, hintId, describedBy } = useOnlineOnly();
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (date) onConfirm(date); if (date && !isOffline) onConfirm(date);
} }
return ( return (
@@ -67,8 +69,9 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm
onChange={(event) => setDate(event.target.value)} onChange={(event) => setDate(event.target.value)}
/> />
</div> </div>
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse"> <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} {confirmLabel}
</Button> </Button>
<SheetClose render={<Button type="button" size="touch" variant="outline" />}> <SheetClose render={<Button type="button" size="touch" variant="outline" />}>
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionCard } from "./suggestion-card"; import { SuggestionCard } from "./suggestion-card";
import { suggestionFixture } from "./suggestion-fixtures"; import { suggestionFixture } from "./suggestion-fixtures";
@@ -18,12 +19,31 @@ function setup(overrides = {}, handlers: Partial<Parameters<typeof SuggestionCar
return { onAccept, onDismiss }; return { onAccept, onDismiss };
} }
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
describe("SuggestionCard", () => { describe("SuggestionCard", () => {
it("the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px", () => { it("the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px", () => {
setup({ mode: "PLAN" }); setup({ mode: "PLAN" });
expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveClass("h-11"); expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveClass("h-11");
expect(screen.getByRole("button", { name: "Verwerfen" })).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", () => { 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("tabindex", "-1");
expect(heading).toHaveAttribute("data-suggestion-heading", "s1"); 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, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DueChip } from "@/components/task-status"; import { DueChip } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels"; import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels";
import type { Suggestion } from "@/lib/suggestions/types"; import type { Suggestion } from "@/lib/suggestions/types";
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; 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" }; 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 (suggestion.intent === "CLEANUP") {
if (!onCleanup) return null; if (!onCleanup) return null;
return ( return (
<Button size="touch" disabled={busy} onClick={() => onCleanup(suggestion)}> <Button
size="touch"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onCleanup(suggestion)}
>
<Check aria-hidden /> <Check aria-hidden />
Aufräumen Aufräumen
</Button> </Button>
@@ -66,7 +81,13 @@ function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }:
} }
if (!canAccept) return null; if (!canAccept) return null;
return ( 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 /> <Check aria-hidden />
Übernehmen Übernehmen
</Button> </Button>
@@ -79,10 +100,14 @@ export function SuggestionCard(props: SuggestionCardProps) {
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const Icon = INTENT_ICONS[suggestion.intent]; const Icon = INTENT_ICONS[suggestion.intent];
const menuAction = canAccept ? menuActionOf(suggestion) : null; 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"; const isUrgent = suggestion.priority === "HIGH" || suggestion.priority === "URGENT";
return ( 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"> <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"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" aria-hidden /> <Icon className="size-5" aria-hidden />
@@ -120,16 +145,24 @@ export function SuggestionCard(props: SuggestionCardProps) {
</div> </div>
</div> </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"> <div className="flex flex-wrap items-center gap-2">
<MainAction {...props} /> <MainAction {...props} isOffline={isOffline} describedBy={describedBy} />
{canDismiss ? ( {canDismiss ? (
<Button <Button
size="touch" size="touch"
variant="ghost" variant="ghost"
disabled={busy} className="px-3"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onDismiss(suggestion)} onClick={() => onDismiss(suggestion)}
> >
<X aria-hidden /> <X aria-hidden className="hidden min-[420px]:block" />
Verwerfen Verwerfen
</Button> </Button>
) : null} ) : null}
@@ -138,9 +171,11 @@ export function SuggestionCard(props: SuggestionCardProps) {
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button <Button
size="touch"
variant="ghost" variant="ghost"
className="size-11" className="ml-auto w-11 shrink-0 px-0"
disabled={busy} disabled={busy || isOffline}
aria-describedby={describedBy}
aria-label="Weitere Aktionen" aria-label="Weitere Aktionen"
/> />
} }
@@ -155,6 +190,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</div> </div>
{hasOnlineOnlyButton ? <OnlineOnlyHint id={hintId} /> : null}
{menuAction ? ( {menuAction ? (
<DueDateSheet <DueDateSheet
open={isSheetOpen} open={isSheetOpen}
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { groupSuggestions } from "@/lib/suggestions/grouping"; import { groupSuggestions } from "@/lib/suggestions/grouping";
import type { Suggestion } from "@/lib/suggestions/types"; import type { Suggestion } from "@/lib/suggestions/types";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionGroup } from "./suggestion-group"; import { SuggestionGroup } from "./suggestion-group";
import { suggestionFixture } from "./suggestion-fixtures"; import { suggestionFixture } from "./suggestion-fixtures";
@@ -138,4 +139,25 @@ describe("SuggestionGroup", () => {
expect(accept[0]).toBeDisabled(); expect(accept[0]).toBeDisabled();
expect(accept[1]).toBeEnabled(); 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 { useId, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Check, ChevronDown, Play } from "lucide-react"; import { Check, ChevronDown, Play } from "lucide-react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels"; import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping"; import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
@@ -55,6 +56,7 @@ function CombinedRow({
const round = intent === "INSPECT" ? roundTarget(suggestions) : null; const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
const canAcceptAll = const canAcceptAll =
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN"); canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id)); const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
return ( return (
@@ -80,7 +82,8 @@ function CombinedRow({
) : canAcceptAll ? ( ) : canAcceptAll ? (
<Button <Button
size="touch" size="touch"
disabled={isBusy} disabled={isBusy || isOffline}
aria-describedby={describedBy}
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))} onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
> >
<Check aria-hidden /> <Check aria-hidden />
@@ -88,6 +91,7 @@ function CombinedRow({
</Button> </Button>
) : null} ) : null}
</div> </div>
{canAcceptAll && !round ? <OnlineOnlyHint id={hintId} /> : null}
<Button <Button
variant="ghost" variant="ghost"
className="min-h-11 self-start" className="min-h-11 self-start"
@@ -97,7 +101,7 @@ function CombinedRow({
> >
<ChevronDown <ChevronDown
aria-hidden aria-hidden
className={cn("transition-transform", isOpen && "rotate-180")} className={cn("motion-safe:transition-transform", isOpen && "rotate-180")}
/> />
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"} {isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button> </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} aria-selected={value === tab.value}
onClick={() => onChange(tab.value)} onClick={() => onChange(tab.value)}
className={cn( 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 value === tab.value
? "bg-card text-foreground shadow-[var(--shadow-soft)]" ? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
@@ -1,11 +1,13 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react"; import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types"; import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today"; import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { SeasonChip } from "@/components/autopilot/season-chip"; import { SeasonChip } from "@/components/autopilot/season-chip";
import { StepActions } from "@/components/autopilot/next-steps-card"; import { StepActions } from "@/components/autopilot/next-steps-card";
import { useStepActions } from "@/components/autopilot/use-step-actions"; 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 { dueChipProps } from "@/lib/autopilot/format";
import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types"; import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { todayLocalIsoDate } from "@/lib/dates"; import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels"; import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { InspectionDueCard } from "./inspection-due-card"; import { InspectionDueCard } from "./inspection-due-card";
@@ -20,6 +23,20 @@ import { useTaskActions } from "./use-task-actions";
const TODAY_LIST_LIMIT = 5; 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> = { const OPEN_RELATED_LABEL: Record<TaskRelatedType, string> = {
COLONY: "Zum Volk", COLONY: "Zum Volk",
APIARY: "Zum Bienenstand", APIARY: "Zum Bienenstand",
@@ -50,6 +67,7 @@ export function TodayPanel({
const actions = useTaskActions(); const actions = useTaskActions();
const stepActions = useStepActions("next-step-heading"); const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const colonyCodes = useColonyCodes(loadedTasks);
// The autopilot is enough to work with when the task list is missing. // The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined); const tasks = loadedTasks ?? (autopilot ? [] : undefined);
@@ -139,7 +157,7 @@ export function TodayPanel({
</h2> </h2>
<Link <Link
href="/tasks" 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 Alle Aufgaben
</Link> </Link>
@@ -166,6 +184,7 @@ export function TodayPanel({
task={task} task={task}
now={now} now={now}
leaving={actions.leavingIds.has(task.id)} leaving={actions.leavingIds.has(task.id)}
colonyCode={task.relatedType === "COLONY" && task.relatedId ? colonyCodes.get(task.relatedId) : undefined}
onComplete={() => actions.complete(task)} onComplete={() => actions.complete(task)}
/> />
))} ))}
@@ -174,7 +193,7 @@ export function TodayPanel({
{hidden > 0 ? ( {hidden > 0 ? (
<Link <Link
href="/tasks" 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 + {hidden} weitere für heute
</Link> </Link>
@@ -195,6 +214,7 @@ interface NextStepCardProps {
function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) { function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) {
const href = relatedHref(task); const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return ( return (
<section <section
aria-labelledby="next-step-heading" aria-labelledby="next-step-heading"
@@ -222,7 +242,12 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> <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 /> <Check aria-hidden />
{leaving ? "Wird gespeichert…" : "Erledigt"} {leaving ? "Wird gespeichert…" : "Erledigt"}
</Button> </Button>
@@ -231,7 +256,8 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
size="touch" size="touch"
variant="outline" variant="outline"
onClick={onStart} onClick={onStart}
disabled={isStarting || leaving} disabled={isStarting || leaving || isOffline}
aria-describedby={describedBy}
> >
<Play aria-hidden /> <Play aria-hidden />
Beginnen Beginnen
@@ -243,6 +269,7 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
<ArrowRight aria-hidden /> <ArrowRight aria-hidden />
</Button> </Button>
) : null} ) : null}
<OnlineOnlyHint id={hintId} className="basis-full" />
</div> </div>
</div> </div>
</section> </section>
@@ -352,11 +379,14 @@ interface TodayRowProps {
task: Task; task: Task;
now: Date; now: Date;
leaving: boolean; leaving: boolean;
/** Code of the colony the task is about, when known. */
colonyCode?: string;
onComplete: () => void; onComplete: () => void;
} }
function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) { function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) {
const href = relatedHref(task); const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return ( return (
<li className="today-row" data-leaving={leaving}> <li className="today-row" data-leaving={leaving}>
<div> <div>
@@ -367,7 +397,8 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
aria-checked={leaving} aria-checked={leaving}
aria-label={`„${task.title}“ als erledigt markieren`} aria-label={`„${task.title}“ als erledigt markieren`}
className="honey-check" className="honey-check"
disabled={leaving} disabled={leaving || isOffline}
aria-describedby={describedBy}
onClick={onComplete} onClick={onComplete}
> >
<svg <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"> <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="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5"> <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} {task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} /> <DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} /> <TaskSourceBadge task={task} />
</span> </span>
<OnlineOnlyHint id={hintId} />
</div> </div>
{href ? ( {href ? (
<Link <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 { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" 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 Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
@@ -11,6 +18,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
position="bottom-right"
offset={TOAST_OFFSET}
mobileOffset={TOAST_OFFSET}
icons={{ icons={{
success: ( success: (
<CircleCheckIcon className="size-4" /> <CircleCheckIcon className="size-4" />
@@ -39,6 +49,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: "cn-toast", 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} {...props}
@@ -7,7 +7,7 @@ const warnings: PlausibilityWarning[] = [
{ {
code: "WITHDRAWAL_ACTIVE", code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG", 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" }], refs: [{ type: "Colony", id: "c-1" }],
}, },
{ {
@@ -28,7 +28,7 @@ describe("WarningList", () => {
expect(within(region).getByText("Warnung")).toBeInTheDocument(); expect(within(region).getByText("Warnung")).toBeInTheDocument();
expect(within(region).getByText("Hinweis")).toBeInTheDocument(); expect(within(region).getByText("Hinweis")).toBeInTheDocument();
expect( 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(); ).toBeInTheDocument();
expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2); expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2);
}); });
@@ -11,6 +11,7 @@ import {
getPack, getPack,
isPackStale, isPackStale,
listPacks, listPacks,
livingColoniesFromPacks,
loadPack, loadPack,
packAgeText, packAgeText,
purgeOldPacks, 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 { ApiError, apiFetch } from "@/lib/auth/api-client";
import { isLivingColony } from "@/lib/colonies/status"; import { isLivingColony } from "@/lib/colonies/status";
import { isNetworkError } from "@/lib/http/fetch-with-timeout"; 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) => return [...byId.values()].sort((a, b) => compareColonyCodes(a.code, b.code));
a.code.localeCompare(b.code, "de", { numeric: true }),
);
} }
/** /**
@@ -1,4 +1,4 @@
import { AutomationTriggerType } from "./types"; import { AutomationRule, AutomationTriggerType } from "./types";
export const RULE_LABELS: Record<AutomationTriggerType, string> = { export const RULE_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte", 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, TREATMENT_FOLLOW_UP: false,
FEEDING_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"; "use client";
import { compareColonyCodes } from "@/lib/colonies/sort";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
@@ -28,7 +29,7 @@ interface Choices<T> {
const BULK_CHOICES_KEY = ["bulk-choices"] as const; const BULK_CHOICES_KEY = ["bulk-choices"] as const;
function byCode(a: BulkColony, b: BulkColony): number { 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[] { function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
@@ -16,6 +16,12 @@ export interface CalendarEvent {
title: string; title: string;
relatedType: string; relatedType: string;
relatedId: 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 { 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 { apiFetch } from "@/lib/auth/api-client";
import { sortColoniesByCode } from "./sort";
import { import {
Colony, Colony,
ColonyHistory, ColonyHistory,
@@ -20,7 +21,7 @@ export function fetchColonies(filter: ColonyFilter = {}): Promise<Colony[]> {
if (filter.status) params.set("status", filter.status); if (filter.status) params.set("status", filter.status);
if (filter.apiaryId) params.set("apiaryId", filter.apiaryId); if (filter.apiaryId) params.set("apiaryId", filter.apiaryId);
const query = params.toString(); 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> { 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"); 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", () => { it("is null without a colony at an apiary", () => {
expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull(); expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull();
expect(roundTarget([])).toBeNull(); expect(roundTarget([])).toBeNull();
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import type { Suggestion, SuggestionIntent } from "./types"; import type { Suggestion, SuggestionIntent } from "./types";
export type SuggestionRow = export type SuggestionRow =
@@ -92,7 +93,7 @@ export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | n
if (apiary === null) return null; if (apiary === null) return null;
const sorted = [...colonies.entries()] const sorted = [...colonies.entries()]
.map(([id, code]) => ({ id, code })) .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 href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`;
const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`; const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`;
return { apiary, colonies: sorted, href, plainHref }; 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 { export interface WithdrawalStatus {
colonyId: string; colonyId: string;
/** First day honey may be taken again (ISO). */
activeUntil: string; 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; product: string;
} }
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({ toast: { warning: vi.fn() } }));
const withdrawal: PlausibilityWarning = { const withdrawal: PlausibilityWarning = {
code: "WITHDRAWAL_ACTIVE", code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG", 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: [], refs: [],
}; };
@@ -20,7 +20,7 @@ describe("toastWarnings", () => {
expect(toast.warning).toHaveBeenCalledWith( expect(toast.warning).toHaveBeenCalledWith(
"Gespeichert · 1 Warnung", "Gespeichert · 1 Warnung",
expect.objectContaining({ 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