diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5e50b47..6baa455 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -340,7 +340,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar ### Jobs -**Aufruf.** `node dist/cli.js [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 [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 „“ 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. diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index ab8b619..575c3d4 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -699,7 +699,8 @@ describe('BottlingService', () => { const warning: PlausibilityWarning = { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', - message: 'B3: Wartezeit nach Oxuvar endet am 20.07.2026.', + message: + 'B3: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.', refs: [{ type: 'Colony', id: 'c1' }], }; diff --git a/ApiCore-Backend/src/calendar/calendar.service.spec.ts b/ApiCore-Backend/src/calendar/calendar.service.spec.ts index 84e792b..a8a8809 100644 --- a/ApiCore-Backend/src/calendar/calendar.service.spec.ts +++ b/ApiCore-Backend/src/calendar/calendar.service.spec.ts @@ -3,6 +3,14 @@ import { PrismaService } from '../prisma/prisma.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; describe('CalendarService', () => { + // The texts add the year outside the current one: pin 'today'. + beforeAll(() => { + jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') }); + }); + afterAll(() => { + jest.useRealTimers(); + }); + let service: CalendarService; let prisma: { task: { findMany: jest.Mock }; @@ -11,6 +19,8 @@ describe('CalendarService', () => { feeding: { findMany: jest.Mock }; harvest: { findMany: jest.Mock }; queenColonyHistory: { findMany: jest.Mock }; + colony: { findMany: jest.Mock }; + apiary: { findMany: jest.Mock }; }; let resolver: { getPermissions: jest.Mock }; @@ -24,6 +34,8 @@ describe('CalendarService', () => { feeding: { findMany: jest.fn().mockResolvedValue([]) }, harvest: { findMany: jest.fn().mockResolvedValue([]) }, queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) }, + colony: { findMany: jest.fn().mockResolvedValue([]) }, + apiary: { findMany: jest.fn().mockResolvedValue([]) }, }; service = new CalendarService( prisma as unknown as PrismaService, @@ -44,7 +56,12 @@ describe('CalendarService', () => { }, ]); prisma.harvest.findMany.mockResolvedValueOnce([ - { id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' }, + { + id: 'h1', + date: new Date('2026-06-10'), + apiaryId: 'a1', + apiary: { name: 'Teststand Nord' }, + }, ]); const events = await service.getEvents( @@ -62,6 +79,73 @@ describe('CalendarService', () => { ]); }); + it('names the colony or apiary of every event in context', async () => { + prisma.task.findMany.mockResolvedValueOnce([ + { + id: 't1', + title: 'Füttern', + dueDate: new Date('2026-06-05'), + relatedType: 'COLONY', + relatedId: 'c1', + }, + { + id: 't2', + title: 'Stand putzen', + dueDate: new Date('2026-06-06'), + relatedType: 'APIARY', + relatedId: 'a1', + }, + { + id: 't3', + title: 'Allgemein', + dueDate: new Date('2026-06-07'), + relatedType: 'GENERAL', + relatedId: null, + }, + ]); + prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-001' }]); + prisma.apiary.findMany.mockResolvedValueOnce([ + { id: 'a1', name: 'Teststand Nord' }, + ]); + prisma.inspection.findMany.mockResolvedValueOnce([ + { + id: 'i1', + date: new Date('2026-06-01'), + colonyId: 'c1', + colony: { code: 'V-001' }, + }, + ]); + prisma.feeding.findMany.mockResolvedValueOnce([ + { + id: 'f1', + date: new Date('2026-06-02'), + colonyId: 'c1', + colony: { code: 'V-001' }, + }, + ]); + prisma.harvest.findMany.mockResolvedValueOnce([ + { + id: 'h1', + date: new Date('2026-06-10'), + apiaryId: 'a1', + apiary: { name: 'Teststand Nord' }, + }, + ]); + + const events = await service.getEvents( + { from: new Date('2026-06-01'), to: new Date('2026-06-30') }, + 'user-1', + ); + + const contextOf = (id: string) => events.find((e) => e.id === id)?.context; + expect(contextOf('i1')).toBe('V-001'); + expect(contextOf('f1')).toBe('V-001'); + expect(contextOf('t1')).toBe('V-001'); + expect(contextOf('t2')).toBe('Teststand Nord'); + expect(contextOf('t3')).toBeUndefined(); + expect(contextOf('h1')).toBe('Teststand Nord'); + }); + it('filters colony-linked events by apiary when apiaryId is given', async () => { await service.getEvents( { @@ -135,6 +219,9 @@ describe('CalendarService', () => { title: 'Wartezeit V-001 · Oxuvar', relatedType: 'Colony', relatedId: 'c1', + context: 'V-001', + product: 'Oxuvar', + detail: 'bis einschließlich 29.06. – ab 30.06. wieder erntefähig', }, ]); }); diff --git a/ApiCore-Backend/src/calendar/calendar.service.ts b/ApiCore-Backend/src/calendar/calendar.service.ts index 47988ed..aafae82 100644 --- a/ApiCore-Backend/src/calendar/calendar.service.ts +++ b/ApiCore-Backend/src/calendar/calendar.service.ts @@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { addDays, berlinDayOf, dayToDate } from '../common/dates'; import { withdrawalSpanOf } from '../withdrawal/withdrawal-span'; +import { + lastBlockedDay, + withdrawalRangeText, +} from '../withdrawal/withdrawal-text'; type TreatmentWithColony = Prisma.TreatmentGetPayload<{ include: { colony: true }; @@ -25,6 +29,12 @@ export interface CalendarEvent { /** WITHDRAWAL only: the last day the span covers (inclusive). */ endDate?: Date; title: string; + /** Colony code or apiary name the event belongs to; absent for general tasks. */ + context?: string; + /** WITHDRAWAL only: the treatment product. */ + product?: string; + /** WITHDRAWAL only: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */ + detail?: string; relatedType: string; relatedId: string; } @@ -71,7 +81,7 @@ export class CalendarService { if (span === null) { return []; } - const lastDay = addDays(span.untilDay, -1); + const lastDay = lastBlockedDay(span.untilDay); if (span.fromDay > toDay || lastDay < fromDay) { return []; } @@ -82,6 +92,9 @@ export class CalendarService { date: dayToDate(span.fromDay), endDate: dayToDate(lastDay), title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`, + context: treatment.colony.code, + product: treatment.product, + detail: withdrawalRangeText(span.untilDay), relatedType: 'Colony', relatedId: treatment.colonyId, }, @@ -89,6 +102,60 @@ export class CalendarService { }); } + /** + * Colony code or apiary name of the tasks that point at one. By ruling only + * COLONY and APIARY tasks get a context; other tasks (queen, honey batch, + * storage, general) have none. + */ + private async taskContexts( + tasks: readonly { + id: string; + relatedType: string; + relatedId: string | null; + }[], + ): Promise> { + 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([ + ...colonies.map((c) => [c.id, c.code] as const), + ...apiaries.map((a) => [a.id, a.name] as const), + ]); + const contexts = new Map(); + for (const task of tasks) { + const name = + task.relatedId === null ? undefined : names.get(task.relatedId); + if (name !== undefined) { + contexts.set(task.id, name); + } + } + return contexts; + } + async getEvents( query: CalendarQuery, userId: string, @@ -132,6 +199,7 @@ export class CalendarService { date: { gte: from, lte: to }, ...(apiaryId ? { apiaryId } : {}), }, + include: { apiary: { select: { name: true } } }, }), this.prisma.queenColonyHistory.findMany({ where: { startDate: { gte: from, lte: to }, ...colonyFilter }, @@ -140,6 +208,8 @@ export class CalendarService { this.withdrawalEvents(query, canReadTreatments), ]); + const taskContexts = await this.taskContexts(tasks); + const events: CalendarEvent[] = [ ...tasks .filter((task) => task.dueDate !== null) @@ -148,6 +218,7 @@ export class CalendarService { type: 'TASK' as const, date: task.dueDate!, title: task.title, + context: taskContexts.get(task.id), relatedType: 'Task', relatedId: task.id, })), @@ -156,6 +227,7 @@ export class CalendarService { type: 'INSPECTION' as const, date: inspection.date, title: `Durchsicht ${inspection.colony.code}`, + context: inspection.colony.code, relatedType: 'Colony', relatedId: inspection.colonyId, })), @@ -164,6 +236,7 @@ export class CalendarService { type: 'TREATMENT' as const, date: treatment.date, title: `Behandlung ${treatment.colony.code} · ${treatment.product}`, + context: treatment.colony.code, relatedType: 'Colony', relatedId: treatment.colonyId, })), @@ -172,6 +245,7 @@ export class CalendarService { type: 'FEEDING' as const, date: feeding.date, title: `Fütterung ${feeding.colony.code}`, + context: feeding.colony.code, relatedType: 'Colony', relatedId: feeding.colonyId, })), @@ -180,6 +254,7 @@ export class CalendarService { type: 'HARVEST' as const, date: harvest.date, title: 'Ernte', + context: harvest.apiary.name, relatedType: 'Apiary', relatedId: harvest.apiaryId, })), @@ -188,6 +263,7 @@ export class CalendarService { type: 'QUEEN_ASSIGNMENT' as const, date: assignment.startDate, title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`, + context: assignment.colony.code, relatedType: 'Queen', relatedId: assignment.queenId, })), diff --git a/ApiCore-Backend/src/common/format-day.spec.ts b/ApiCore-Backend/src/common/format-day.spec.ts new file mode 100644 index 0000000..f7c3bae --- /dev/null +++ b/ApiCore-Backend/src/common/format-day.spec.ts @@ -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.'); + }); +}); diff --git a/ApiCore-Backend/src/common/format-day.ts b/ApiCore-Backend/src/common/format-day.ts new file mode 100644 index 0000000..15fb066 --- /dev/null +++ b/ApiCore-Backend/src/common/format-day.ts @@ -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}.`; +} diff --git a/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts b/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts index 8a0b966..7bd8b4d 100644 --- a/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts +++ b/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts @@ -23,7 +23,8 @@ describe('plausibility warnings', () => { const warning: PlausibilityWarning = { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', - message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.', + message: + 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', refs: [{ type: 'Colony', id: 'c1' }], }; diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index 12f908d..143ceec 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -260,7 +260,8 @@ describe('HarvestsService', () => { const warning: PlausibilityWarning = { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', - message: 'V-1: Wartezeit nach Oxuvar endet am 20.07.2026.', + message: + 'V-1: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.', refs: [{ type: 'Colony', id: 'colony-1' }], }; diff --git a/ApiCore-Backend/src/suggestions/suggestion-texts.ts b/ApiCore-Backend/src/suggestions/suggestion-texts.ts index 1b6dbda..981aee6 100644 --- a/ApiCore-Backend/src/suggestions/suggestion-texts.ts +++ b/ApiCore-Backend/src/suggestions/suggestion-texts.ts @@ -106,5 +106,5 @@ export function renderSuggestion( return override ? { ...rendered, title: override } : rendered; } -export { formatDayDe } from './texts/format-day'; +export { formatDayDe } from '../common/format-day'; export { formatNumberDe } from './texts/format-number'; diff --git a/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts b/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts index b5a9b03..f08b5af 100644 --- a/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts +++ b/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts @@ -28,3 +28,35 @@ describe('chain texts', () => { expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle'); }); }); + +describe('chain texts: sentence ends', () => { + const lookup = { + colonyCode: () => 'V-1', + queenCode: () => 'Q-1', + treatmentProduct: () => null, + apiaryName: () => null, + stockItemName: () => null, + }; + + it('never ends a reason with two periods when a day closes the sentence', () => { + for (const code of Object.keys(CHAIN_TEXTS)) { + const reasons = CHAIN_TEXTS[code].render( + CHAIN_TEXTS[code].example, + lookup, + '2026-05-05', + ).reasons; + for (const reason of reasons) { + expect(reason).not.toMatch(/\.\./); + } + } + }); + + it('writes the split day with a single period', () => { + const reasons = CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.render( + CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.example, + lookup, + '2026-05-05', + ).reasons; + expect(reasons[0]).toBe('Ableger von V-1 am 01.05.'); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/texts/chain-texts.ts b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts index d0f722e..9c84bab 100644 --- a/ApiCore-Backend/src/suggestions/texts/chain-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts @@ -2,7 +2,7 @@ import type { SuggestionTextRegister, SuggestionTextTemplate, } from '../suggestion-texts'; -import { formatDayDe } from './format-day'; +import { endSentence, formatDayDe } from '../../common/format-day'; import type { EvidenceLookup } from '../evidence-lookup'; import type { SuggestionEvidence } from '../suggestion.types'; @@ -39,7 +39,9 @@ function splitTemplate( render: (evidence, lookup) => ({ title, reasons: [ - `Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`, + endSentence( + `Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}`, + ), reason, ], }), @@ -59,7 +61,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = { render: (evidence, lookup) => ({ title: 'Schwarmkontrolle', reasons: [ - `Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`, + endSentence( + `Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden`, + ), ], }), }, @@ -80,7 +84,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = { return { title: 'Annahme der Königin prüfen', reasons: [ - `${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`, + endSentence( + `${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt`, + ), ], }; }, diff --git a/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts index 853abde..a170a4f 100644 --- a/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts @@ -1,5 +1,5 @@ import type { SuggestionTextRegister } from '../suggestion-texts'; -import { formatDayDe } from './format-day'; +import { formatDayDe } from '../../common/format-day'; const ENDED_VERBS: Readonly> = { DECEASED: 'ist eingegangen', diff --git a/ApiCore-Backend/src/suggestions/texts/format-day.ts b/ApiCore-Backend/src/suggestions/texts/format-day.ts deleted file mode 100644 index 3e21dcc..0000000 --- a/ApiCore-Backend/src/suggestions/texts/format-day.ts +++ /dev/null @@ -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}.`; -} diff --git a/ApiCore-Backend/src/suggestions/texts/rule-texts.ts b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts index cf88405..6e6c0a3 100644 --- a/ApiCore-Backend/src/suggestions/texts/rule-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts @@ -1,4 +1,4 @@ -import { formatDayDe } from './format-day'; +import { formatDayDe } from '../../common/format-day'; import { formatNumberDe } from './format-number'; import type { RenderedSuggestionText, diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index 64c86de..c5622c2 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -6,6 +6,14 @@ import { DomainEventsService } from '../domain-events/domain-events.service'; import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; describe('TreatmentsService', () => { + // The texts add the year outside the current one: pin 'today'. + beforeAll(() => { + jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') }); + }); + afterAll(() => { + jest.useRealTimers(); + }); + let service: TreatmentsService; let prisma: { treatment: { findMany: jest.Mock; findUnique: jest.Mock }; @@ -499,6 +507,9 @@ describe('TreatmentsService', () => { { colonyId: 'colony-1', activeUntil: '2026-07-15T00:00:00.000Z', + lastBlockedDay: '2026-07-14', + withdrawalText: + 'bis einschließlich 14.07. – ab 15.07. wieder erntefähig', product: 'Ameisensäure', }, ]); @@ -587,6 +598,9 @@ describe('TreatmentsService', () => { { colonyId: 'colony-1', activeUntil: '2026-07-27T00:00:00.000Z', + lastBlockedDay: '2026-07-26', + withdrawalText: + 'bis einschließlich 26.07. – ab 27.07. wieder erntefähig', product: 'Ameisensäure', }, ]); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index 146e458..525b0c9 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -15,6 +15,10 @@ import { import { runBulk } from '../common/bulk/run-bulk'; import { lockRowForUpdate } from '../prisma/row-lock'; import { berlinDayOf, dayToDate } from '../common/dates'; +import { + lastBlockedDay, + withdrawalRangeText, +} from '../withdrawal/withdrawal-text'; import { isInWindow, withdrawalWindowOf, @@ -27,6 +31,10 @@ import { UpdateTreatmentDto } from './dto/update-treatment.dto'; export interface WithdrawalStatus { colonyId: string; activeUntil: string; + /** Last blocked day (inclusive), 'YYYY-MM-DD'; activeUntil is the day after. */ + lastBlockedDay: string; + /** Ready text: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */ + withdrawalText: string; product: string; } @@ -116,6 +124,8 @@ export class TreatmentsService { return [...activeByColony.values()].map((window) => ({ colonyId: window.colonyId, activeUntil: dayToDate(window.untilDay).toISOString(), + lastBlockedDay: lastBlockedDay(window.untilDay), + withdrawalText: withdrawalRangeText(window.untilDay), product: window.product, })); } diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts index ae5a8a1..f244dec 100644 --- a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts +++ b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts @@ -40,6 +40,14 @@ const conflict = ( }); describe('WithdrawalCheckService', () => { + // The texts add the year outside the current one: pin 'today'. + beforeAll(() => { + jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') }); + }); + afterAll(() => { + jest.useRealTimers(); + }); + let db: { harvest: { findMany: jest.Mock }; treatment: { findMany: jest.Mock; findUnique: jest.Mock }; @@ -269,7 +277,8 @@ describe('WithdrawalCheckService', () => { { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', - message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.', + message: + 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', refs: [ { type: 'Colony', id: 'c1' }, { type: 'Treatment', id: 't1' }, @@ -279,6 +288,25 @@ describe('WithdrawalCheckService', () => { ]); }); + it('warns once per colony and treatment when several harvests feed the batch', async () => { + const warnings = await service.warningsFor(asDb(), [ + conflict({ harvestId: 'h1' }), + conflict({ harvestId: 'h2' }), + conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }), + ]); + + expect(warnings.map((w) => w.message)).toEqual([ + 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', + 'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', + ]); + expect(warnings[0].refs).toEqual([ + { type: 'Colony', id: 'c1' }, + { type: 'Treatment', id: 't1' }, + { type: 'Harvest', id: 'h1' }, + { type: 'Harvest', id: 'h2' }, + ]); + }); + it('answers nothing without reading when there is no conflict', async () => { expect(await service.warningsFor(asDb(), [])).toEqual([]); expect(db.colony.findMany).not.toHaveBeenCalled(); @@ -300,7 +328,7 @@ describe('WithdrawalCheckService', () => { priority: 'WARNUNG', category: 'WITHDRAWAL', message: - 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.', + 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', relatedType: 'Harvest', relatedId: 'h1', link: '/harvests/h1', @@ -337,7 +365,7 @@ describe('WithdrawalCheckService', () => { { message: string; dedupeKeyPrefix: string }, ]; expect(input.message).toBe( - 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.', + 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.', ); // Same key as without the lots: one notification per harvest and colony. expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1'); diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts index de954e3..fe2293f 100644 --- a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts +++ b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts @@ -7,13 +7,14 @@ import { } from '../common/warnings/plausibility-warning'; import { NotificationsService } from '../notifications/notifications.service'; import { assertTransactionClient, type DbClient } from '../prisma/db-client'; -import { formatDayDe } from '../suggestions/texts/format-day'; +import { formatDayDe } from '../common/format-day'; import { isInWindow, withdrawalWindowOf, type WithdrawalWindow, } from '../treatments/withdrawal-window'; import { withdrawalSpanOf } from './withdrawal-span'; +import { withdrawalRangeText } from './withdrawal-text'; /** A harvest taken from a colony while a treatment's withdrawal period ran. */ export interface WithdrawalConflict { @@ -49,6 +50,22 @@ function longestCovering( ); } +/** + * One group per (colony, treatment): several harvests of a colony that feed + * the same batch yield one warning, not one per harvest. Keeps the order of + * first appearance. + */ +function perColonyAndTreatment( + conflicts: readonly WithdrawalConflict[], +): WithdrawalConflict[][] { + const groups = new Map(); + for (const conflict of conflicts) { + const key = `${conflict.colonyId}:${conflict.treatmentId}`; + groups.set(key, [...(groups.get(key) ?? []), conflict]); + } + return [...groups.values()]; +} + /** * Checks harvests against the withdrawal windows (Wartezeit) of their * colonies. Only the window helper decides what a window is @@ -190,16 +207,22 @@ export class WithdrawalCheckService { return []; } const codes = await this.colonyCodes(db, conflicts); - return conflicts.map((conflict) => ({ - code: 'WITHDRAWAL_ACTIVE', - severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE, - message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`, - refs: [ - { type: 'Colony', id: conflict.colonyId }, - { type: 'Treatment', id: conflict.treatmentId }, - { type: 'Harvest', id: conflict.harvestId }, - ], - })); + return perColonyAndTreatment(conflicts).map((group) => { + const [first] = group; + return { + code: 'WITHDRAWAL_ACTIVE', + severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE, + message: `${codes.get(first.colonyId) ?? first.colonyId}: Wartezeit nach ${first.product} ${withdrawalRangeText(first.untilDay)}.`, + refs: [ + { type: 'Colony', id: first.colonyId }, + { type: 'Treatment', id: first.treatmentId }, + ...group.map((conflict) => ({ + type: 'Harvest', + id: conflict.harvestId, + })), + ], + }; + }); } /** @@ -234,7 +257,7 @@ export class WithdrawalCheckService { { priority: 'WARNUNG', category: 'WITHDRAWAL', - message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.${bottledText}`, + message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} ${withdrawalRangeText(conflict.untilDay)}.${bottledText}`, relatedType: 'Harvest', relatedId: conflict.harvestId, link: `/harvests/${conflict.harvestId}`, diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-text.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal-text.spec.ts new file mode 100644 index 0000000..e0c1fb3 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-text.spec.ts @@ -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', + ); + }); +}); diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-text.ts b/ApiCore-Backend/src/withdrawal/withdrawal-text.ts new file mode 100644 index 0000000..735663a --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-text.ts @@ -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`; +} diff --git a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts index 0dde4cd..12fe547 100644 --- a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts +++ b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts @@ -39,6 +39,14 @@ const treatmentRow = (overrides: Record = {}) => ({ }); describe('WithdrawalEventHandlers', () => { + // The texts add the year outside the current one: pin 'today'. + beforeAll(() => { + jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') }); + }); + afterAll(() => { + jest.useRealTimers(); + }); + let check: { conflictsForHarvests: jest.Mock; conflictsForHoneyBatch: jest.Mock; @@ -193,7 +201,7 @@ describe('WithdrawalEventHandlers', () => { priority: 'INFO', category: 'WITHDRAWAL', message: - 'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.', + 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', relatedType: 'Colony', relatedId: 'c1', link: '/colonies/c1', diff --git a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts index 9aab809..98edf33 100644 --- a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts +++ b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts @@ -5,12 +5,12 @@ import { isLivingColonyStatus } from '../colonies/colony-status'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { NotificationsService } from '../notifications/notifications.service'; -import { formatDayDe } from '../suggestions/texts/format-day'; import { isInWindow, withdrawalWindowOf, } from '../treatments/withdrawal-window'; import { WithdrawalCheckService } from './withdrawal-check.service'; +import { withdrawalRangeText } from './withdrawal-text'; const READY_RECIPIENT_PERMISSIONS = [ 'harvests:create', @@ -123,7 +123,7 @@ export class WithdrawalEventHandlers { { priority: 'INFO', category: 'WITHDRAWAL', - message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`, + message: `${treatment.colony.code}: Wartezeit nach ${treatment.product} ${withdrawalRangeText(endedOn)}.`, relatedType: 'Colony', relatedId: treatment.colonyId, link: `/colonies/${treatment.colonyId}`, diff --git a/ApiCore-Backend/test/calendar.e2e-spec.ts b/ApiCore-Backend/test/calendar.e2e-spec.ts index 4560eb3..7f07f4f 100644 --- a/ApiCore-Backend/test/calendar.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar.e2e-spec.ts @@ -1,4 +1,5 @@ import { INestApplication } from '@nestjs/common'; +import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -152,6 +153,9 @@ describe('Calendar (e2e)', () => { ); expect(span).toMatchObject({ title: `Wartezeit ${colonyCode} · Oxuvar`, + context: colonyCode, + product: 'Oxuvar', + detail: withdrawalRangeText('2027-06-20'), relatedType: 'Colony', relatedId: colonyId, }); diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index d1d5960..fcf45e4 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -16,6 +16,8 @@ interface TreatmentBody { interface WithdrawalStatusBody { colonyId: string; activeUntil: string; + lastBlockedDay: string; + withdrawalText: string; product: string; } @@ -254,6 +256,9 @@ describe('Treatments (e2e)', () => { { colonyId, activeUntil: '2026-07-15T00:00:00.000Z', + lastBlockedDay: '2026-07-14', + withdrawalText: + 'bis einschließlich 14.07. – ab 15.07. wieder erntefähig', product: 'Oxalsäure', }, ]); diff --git a/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts b/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts index 45d6090..43e7ca5 100644 --- a/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts +++ b/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts @@ -1,4 +1,5 @@ import { INestApplication } from '@nestjs/common'; +import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -264,7 +265,7 @@ describe('Withdrawal chain (e2e)', () => { { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', - message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`, + message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, refs: [ { type: 'Colony', id: colony.id }, { type: 'Treatment', id: treatmentIds[0] }, @@ -283,7 +284,7 @@ describe('Withdrawal chain (e2e)', () => { expect(rows[0]).toMatchObject({ priority: 'WARNUNG', category: 'WITHDRAWAL', - message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`, + message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, relatedType: 'Harvest', relatedId: harvestId, link: `/harvests/${harvestId}`, @@ -332,7 +333,7 @@ describe('Withdrawal chain (e2e)', () => { expect(bottling.warnings).toHaveLength(1); expect(bottling.warnings[0]).toMatchObject({ code: 'WITHDRAWAL_ACTIVE', - message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`, + message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, }); await drainDomainEvents(app, [bottling.id]); @@ -515,7 +516,7 @@ describe('Withdrawal chain (e2e)', () => { expect(rows[0]).toMatchObject({ priority: 'INFO', category: 'WITHDRAWAL', - message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`, + message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-06-25')}.`, relatedType: 'Colony', link: `/colonies/${colony.id}`, }); @@ -558,7 +559,7 @@ describe('Withdrawal chain (e2e)', () => { where: { userId: readerId, relatedId: colony.id }, }); expect(rows).toHaveLength(1); - expect(rows[0].message).toContain('endete am 03.06.2026'); + expect(rows[0].message).toContain(withdrawalRangeText('2026-06-03')); expect(rows[0].message).toContain(colony.code); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx index ea9dc46..782d808 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx @@ -4,7 +4,8 @@ import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import NewBottlingBatchPage from "./page"; import { createBottlingBatch } from "@/lib/bottling/api"; -import { fetchHoneyBatches } from "@/lib/honey-batches/api"; +import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api"; +import { fetchWithdrawalStatus } from "@/lib/treatments/api"; import { fetchProducts } from "@/lib/products/api"; import { toast } from "sonner"; @@ -13,7 +14,11 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push }), useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"), })); -vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() })); +vi.mock("@/lib/honey-batches/api", () => ({ + fetchHoneyBatches: vi.fn(), + fetchHoneyBatchTraceability: vi.fn(), +})); +vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() })); vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() })); vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() })); vi.mock("sonner", () => ({ @@ -39,6 +44,8 @@ describe("NewBottlingBatchPage", () => { { id: "hb-1", batchNumber: "2026-01", amount: 40 }, ] as never); vi.mocked(fetchProducts).mockResolvedValue([] as never); + vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never); + vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]); vi.mocked(createBottlingBatch).mockReset(); vi.mocked(toast.success).mockReset(); vi.mocked(toast.warning).mockReset(); @@ -100,7 +107,7 @@ describe("NewBottlingBatchPage", () => { { code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", - message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.", refs: [{ type: "Colony", id: "c-3" }], }, ], @@ -114,10 +121,45 @@ describe("NewBottlingBatchPage", () => { expect(toast.warning).toHaveBeenCalledWith( "Abfüllung angelegt · 1 Warnung", expect.objectContaining({ - description: expect.stringMatching(/B3.*20\.08\.2026/), + description: expect.stringMatching(/B3.*20.08./), }), ); expect(toast.success).not.toHaveBeenCalled(); expect(push).toHaveBeenCalledWith("/bottling/b-1"); }); + + it("shows the withdrawal of a colony behind the batch once, before saving", async () => { + vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ + harvests: [ + { id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] }, + { id: "h-2", date: "2026-10-15T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }, { id: "c-3", code: "V-03" }] }, + ], + } as never); + vi.mocked(fetchWithdrawalStatus).mockImplementation(async (ids) => + ids.includes("c-2") + ? [{ colonyId: "c-2", activeUntil: "2026-10-27T00:00:00.000Z", lastBlockedDay: "2026-10-26", withdrawalText: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig", product: "Oxuvar" }] + : [], + ); + renderPage(); + + const hints = await screen.findAllByText(/V-02: Wartezeit nach Oxuvar bis einschließlich 26\.10\./); + expect(hints).toHaveLength(1); + expect(hints[0]).toHaveTextContent("ab 27.10. wieder erntefähig"); + expect(fetchWithdrawalStatus).toHaveBeenCalledWith(expect.arrayContaining(["c-2"]), "2026-10-14"); + }); + + it("shows no hint when the batch has no harvest inside a window", async () => { + renderPage(); + await screen.findByRole("button", { name: "Abfüllung anlegen" }); + expect(screen.queryByText(/Wartezeit nach/)).not.toBeInTheDocument(); + }); + + it("says so when the withdrawal check fails instead of showing nothing", async () => { + vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ + harvests: [{ id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] }], + } as never); + vi.mocked(fetchWithdrawalStatus).mockRejectedValue(new Error("offline")); + renderPage(); + expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx index c5bf00a..d8f8fd0 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx @@ -4,10 +4,11 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Package } from "lucide-react"; +import { AlertTriangle, Package } from "lucide-react"; import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchProducts } from "@/lib/products/api"; import { createBottlingBatch } from "@/lib/bottling/api"; +import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; import { toastWarnings } from "@/lib/warnings/toast-warnings"; @@ -88,6 +89,10 @@ export default function NewBottlingBatchPage() { queryFn: () => fetchProducts({ status: "ACTIVE" }), }); + const { hints: withdrawalHints, failed: withdrawalCheckFailed } = useBottlingWithdrawalHints( + values.honeyBatchId, + ); + function set( key: K, value: BottlingFormValues[K], @@ -197,6 +202,27 @@ export default function NewBottlingBatchPage() { /> )} + {withdrawalCheckFailed ? ( +

+ + Wartezeit konnte nicht geprüft werden +

+ ) : null} + {withdrawalHints.length > 0 ? ( +
+

+ + Honig aus einer Wartezeit +

+
    + {withdrawalHints.map((hint) => ( +
  • + {hint.colonyCode}: Wartezeit nach {hint.product} {hint.withdrawalText}. +
  • + ))} +
+
+ ) : null} {({ id, describedBy, invalid }) => ( { const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0]; expect(link).toHaveAttribute("href", "/colonies/c-3"); - const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Einträge/ }); + const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Eintrag$/ }); await userEvent.click(day); expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument(); }); @@ -102,3 +102,144 @@ describe("CalendarPage withdrawal spans", () => { expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument(); }); }); + +describe("CalendarPage entries", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z")); + vi.mocked(fetchApiaries).mockResolvedValue([] as never); + }); + + afterEach(() => vi.useRealTimers()); + + it("names the colony or apiary on rows that do not carry it in their title", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "task:1", + type: "TASK", + date: "2026-08-12T00:00:00.000Z", + title: "Fütterung erforderlich: Futterstatus kritisch.", + relatedType: "Task", + relatedId: "t-1", + context: "V-07", + }, + { + id: "h:1", + type: "HARVEST", + date: "2026-08-12T00:00:00.000Z", + title: "Ernte", + relatedType: "Apiary", + relatedId: "a-1", + context: "Teststand Nord", + }, + { + id: "i:1", + type: "INSPECTION", + date: "2026-08-12T00:00:00.000Z", + title: "Durchsicht V-01", + relatedType: "Colony", + relatedId: "c-1", + context: "V-01", + }, + ] as never); + renderPage(); + const phoneList = (await screen.findAllByText("Ernte", { selector: "span.truncate" }))[0].closest( + "div.sm\\:hidden", + ) as HTMLElement; + expect(within(phoneList).getByText("V-07")).toBeInTheDocument(); + expect(within(phoneList).getByText("Teststand Nord")).toBeInTheDocument(); + // The title already says V-01: no second mention. + expect(within(phoneList).queryByText("V-01")).not.toBeInTheDocument(); + }); + + it("gives month chips the full text as title and accessible name", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "withdrawal:t-1", + type: "WITHDRAWAL", + date: "2026-08-10T00:00:00.000Z", + endDate: "2026-08-12T00:00:00.000Z", + title: "Wartezeit B3 · Oxuvar", + relatedType: "Colony", + relatedId: "c-3", + detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig", + }, + ]); + renderPage(); + const day = await screen.findByRole("button", { name: /Dienstag, 11\. August/ }); + const chip = within(day).getByText("Wartezeit B3 · Oxuvar").closest("span[title]") as HTMLElement; + expect(chip).toHaveAttribute( + "title", + "Wartezeit B3 · Oxuvar – Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig", + ); + }); + + it("labels the end of a withdrawal span in its rows", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "withdrawal:t-1", + type: "WITHDRAWAL", + date: "2026-08-10T00:00:00.000Z", + endDate: "2026-08-12T00:00:00.000Z", + title: "Wartezeit B3 · Oxuvar", + relatedType: "Colony", + relatedId: "c-3", + detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig", + }, + ]); + renderPage(); + const link = (await screen.findAllByRole("link", { name: /Wartezeit B3/ }))[0]; + expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig"); + }); + + it("counts a day with several entries in the plural", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" }, + { id: "b", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "B", relatedType: "Task", relatedId: "2" }, + ]); + renderPage(); + expect(await screen.findByRole("button", { name: /Mittwoch, 12\. August.*2 Einträge/ })).toBeInTheDocument(); + }); + + it("matches the colony code as a whole word (V-1 is not hidden by V-10)", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "i:1", + type: "INSPECTION", + date: "2026-08-12T00:00:00.000Z", + title: "Durchsicht V-10", + relatedType: "Colony", + relatedId: "c-1", + context: "V-1", + }, + ]); + renderPage(); + const row = (await screen.findAllByRole("link", { name: /Durchsicht V-10/ }))[0]; + expect(within(row).getByText("V-1", { selector: "span.shrink-0" })).toBeInTheDocument(); + }); + + it("says the withdrawal span once on its first day and marks the following days compactly", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "withdrawal:t-1", + type: "WITHDRAWAL", + date: "2026-08-10T00:00:00.000Z", + endDate: "2026-08-12T00:00:00.000Z", + title: "Wartezeit B3 · Oxuvar", + relatedType: "Colony", + relatedId: "c-3", + context: "B3", + product: "Ameisensäure 60 %", + detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig", + }, + ]); + renderPage(); + const rows = await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" }); + const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement; + expect(within(phoneList).getAllByText(/wieder erntefähig/)).toHaveLength(1); + expect(within(phoneList).getAllByText(/Wartezeit läuft weiter/)).toHaveLength(2); + // The product is on its own line, not cut off with the title. + expect(within(phoneList).getByText(/Ameisensäure 60 %/)).toBeInTheDocument(); + expect(within(phoneList).getAllByRole("link", { name: /Wartezeit/ })).toHaveLength(3); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/calendar/page.tsx b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx index 1a7f137..1a32ff0 100644 --- a/ApiCore-Frontend/src/app/(app)/calendar/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx @@ -26,6 +26,7 @@ import { ErrorState, LoadingState } from "@/components/query-state"; import { RecordSelect } from "@/components/form/record-select"; import { cn } from "@/lib/utils"; import { toLocalIsoDate } from "@/lib/dates"; +import { entryCountText } from "@/lib/text/plural"; const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -59,6 +60,36 @@ function eventDayKeys(event: CalendarEvent): string[] { ); } +/** True when `word` stands in `text` as a whole token: "V-1" is not said by "V-10". */ +function mentionsWord(text: string, word: string): boolean { + const isWordChar = (char: string | undefined) => char !== undefined && /[A-Za-z0-9_-]/.test(char); + let from = text.indexOf(word); + while (from !== -1) { + const end = from + word.length; + if (!isWordChar(text[from - 1]) && !isWordChar(text[end])) return true; + from = text.indexOf(word, from + 1); + } + return false; +} + +/** Colony code or apiary name, unless the title already says it. */ +function eventContext(event: CalendarEvent): string | null { + const context = event.context || null; + if (!context) return null; + return mentionsWord(event.title, context) ? null : context; +} + +/** For a withdrawal span: until when honey is blocked and when it is free again. */ +function eventNote(event: CalendarEvent): string | null { + if (event.type !== "WITHDRAWAL") return null; + return event.detail ? `Wartezeit ${event.detail}` : null; +} + +/** Everything a row says, as one text - the tooltip of a truncated month chip. */ +function eventFullText(event: CalendarEvent): string { + return [event.title, eventContext(event), eventNote(event)].filter(Boolean).join(" – "); +} + function eventHref(event: CalendarEvent): string | null { if (event.relatedType === "Task") return "/tasks"; if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`; @@ -82,15 +113,36 @@ function dayHeading(date: Date): string { return date.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" }); } -function EventRow({ event }: { event: CalendarEvent }) { +function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuation: boolean }) { const meta = EVENT_META[event.type]; const Icon = meta.icon; const href = eventHref(event); - const content = ( - + const context = eventContext(event); + const note = isContinuation ? null : eventNote(event); + const product = isContinuation ? null : (event.product ?? null); + const content = isContinuation ? ( + + + {meta.label} + + {[context, event.title].filter(Boolean).join(" · ")} – Wartezeit läuft weiter + + + ) : ( + {meta.label} - {event.title} + + + {context ? {context} : null} + {event.title} + + {product || note ? ( + + {[product, note].filter(Boolean).join(" – ")} + + ) : null} + ); return href ? ( @@ -106,10 +158,13 @@ function DayEvents({ date, events, isToday, + isContinuation = () => false, }: { date: Date; events: CalendarEvent[]; isToday: boolean; + /** True for a withdrawal span that already showed in full on an earlier day of the list. */ + isContinuation?: (event: CalendarEvent) => boolean; }) { return (
@@ -127,7 +182,7 @@ function DayEvents({
    {events.map((event) => (
  • - +
  • ))}
@@ -180,6 +235,14 @@ export default function CalendarPage() { (day) => day.getMonth() === month.getMonth() && (eventsByDay.get(toLocalIsoDate(day))?.length ?? 0) > 0, ); + // A withdrawal span is told in full on the first listed day, then only marked. + const firstShownDay = new Map(); + for (const day of daysOfMonthWithEvents) { + const key = toLocalIsoDate(day); + for (const event of eventsByDay.get(key) ?? []) { + if (event.type === "WITHDRAWAL" && !firstShownDay.has(event.id)) firstShownDay.set(event.id, key); + } + } const selectedDay = gridDays.find((day) => toLocalIsoDate(day) === selectedKey); function goToMonth(offset: number) { @@ -258,6 +321,9 @@ export default function CalendarPage() { date={day} events={eventsByDay.get(toLocalIsoDate(day)) ?? []} isToday={toLocalIsoDate(day) === todayKey} + isContinuation={(event) => + event.type === "WITHDRAWAL" && firstShownDay.get(event.id) !== toLocalIsoDate(day) + } /> )) )} @@ -286,7 +352,7 @@ export default function CalendarPage() { type="button" onClick={() => setSelectedKey(key)} aria-pressed={isSelected} - aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${dayEvents.length} Einträge`} + aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`} className={cn( "flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0", isCurrentMonth ? "bg-card" : "bg-muted/40", @@ -317,13 +383,16 @@ export default function CalendarPage() { return ( - {event.title} + + {[eventContext(event), event.title].filter(Boolean).join(" · ")} + ); })} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx index b7682af..9556946 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx @@ -121,6 +121,7 @@ describe("ColonyActions – colonies that need attention", () => { for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) { expect(screen.getByRole("button", { name })).toBeEnabled(); + expect(screen.getByRole("button", { name })).toHaveClass("min-h-11"); } expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument(); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 1c21fed..9a53847 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -95,7 +95,7 @@ function MoveDialog({ colony }: { colony: Colony }) { + } @@ -216,7 +216,7 @@ function SplitDialog({ colony }: { colony: Colony }) { + } @@ -325,7 +325,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { + } @@ -398,7 +398,7 @@ function MergeDialog({ colony }: { colony: Colony }) { + } @@ -461,6 +461,7 @@ function DissolveButton({ colony }: { colony: Colony }) { return ( ({ updateColony: vi.fn() })); @@ -228,4 +229,19 @@ describe("ColonyEditCard – legacy strength after clearing", () => { expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength"); }); + + it("shows the cached apiary name read-only while the apiary list cannot be loaded", async () => { + vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("offline")); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["apiaries", "apiary-1"], { id: "apiary-1", name: "Obstwiese" }); + render( + + + , + ); + + expect(await screen.findByText("Obstwiese")).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: /Bienenstand/ })).not.toBeInTheDocument(); + expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index eadcc81..6aba25e 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -89,10 +89,18 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { ); const [notes, setNotes] = useState(colony.notes ?? ""); - const { data: apiaries } = useQuery({ + const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries(), }); + const apiaries = apiariesQuery.data; + // Without the list (offline, or it failed) the select would sit on "Wird + // geladen…" for good: show the apiary by its cached name instead. + const apiaryListUnavailable = !apiaries && apiariesQuery.fetchStatus !== "fetching"; + const cachedApiaryName = + apiaries?.find((apiary) => apiary.id === colony.apiaryId)?.name ?? + queryClient.getQueryData<{ name: string }>(["apiaries", colony.apiaryId])?.name ?? + null; const updateMutation = useMutation({ mutationFn: () => @@ -136,15 +144,21 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
- setApiaryId(value || apiaryId)} - items={apiaries} - getLabel={(apiary) => apiary.name} - placeholder="Bienenstand wählen" - /> + {apiaryListUnavailable ? ( +

+ {cachedApiaryName ?? "Bienenstand derzeit nicht ladbar"} +

+ ) : ( + setApiaryId(value || apiaryId)} + items={apiaries} + getLabel={(apiary) => apiary.name} + placeholder="Bienenstand wählen" + /> + )}

Eine Korrektur wird wie ein Umsetzen in der Standort-Historie festgehalten. diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx index b115842..2226d69 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx @@ -57,7 +57,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) { {(feedings) => feedings.map((feeding) => (

-
+

{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "} {FEED_TYPE_LABELS[feeding.feedType]} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-inspections.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-inspections.tsx index 1355dc9..92f5641 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-inspections.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-inspections.tsx @@ -44,7 +44,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) { {(inspections) => inspections.map((inspection) => (

-
+

{new Date(inspection.date).toLocaleDateString("de-DE")}

diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx index 0f9de0e..f8f20bc 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx @@ -46,7 +46,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) { {(checks) => checks.map((check) => (
-
+

{new Date(check.date).toLocaleDateString("de-DE")}

diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx index a21c418..1bca6ca 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx @@ -44,7 +44,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) { {(treatments) => treatments.map((treatment) => (
-
+

{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}

diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx index 7ed04ed..7db1d34 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ColonyVarroaCard } from "./colony-varroa"; import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api"; @@ -188,4 +188,33 @@ describe("ColonyVarroaCard trend", () => { expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument(); expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument(); }); + + it("lets a row's heading and edit button wrap instead of overflowing the card", async () => { + renderCard(); + await screen.findByRole("img"); + const row = screen.getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /)[0].parentElement; + expect(row).toHaveClass("flex-wrap"); + }); + + it("lists the measurements newest first", async () => { + renderCard(); + await screen.findByRole("img"); + const dates = screen + .getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /) + .map((node) => node.textContent?.split(" · ")[0]); + expect(dates).toEqual(["15.6.2026", "8.6.2026", "1.6.2026"]); + }); + + it("labels the date axis and explains the lines in a legend", async () => { + renderCard(); + const chart = await screen.findByRole("img"); + const axis = within(chart as unknown as HTMLElement); + expect(axis.getByText("01.06.2026")).toBeInTheDocument(); + expect(axis.getByText("15.06.2026")).toBeInTheDocument(); + await screen.findByText("Rote Grenze"); + const legend = screen.getByRole("list", { name: "Legende" }); + expect(within(legend).getByText("Milbenfall pro Tag")).toBeInTheDocument(); + expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument(); + expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx index 0d626da..a73fc8d 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx @@ -18,12 +18,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { VarroaEditDialog } from "./varroa-edit-dialog"; const CHART_WIDTH = 300; -const CHART_HEIGHT = 120; +const CHART_HEIGHT = 136; const PAD_X = 12; const PAD_TOP = 12; -const PAD_BOTTOM = 12; +const PAD_BOTTOM = 28; const MARKER_RADIUS = 4; const LABEL_GAP = 4; +const AXIS_LABEL_SIZE = 10; const MAX_BAND_MONTHS = 24; const ONE_HOUR_MS = 60 * 60 * 1000; @@ -74,6 +75,13 @@ function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] { .sort((left, right) => left.time - right.time); } +/** Newest first, like the inspections; on the same day the later entry comes first. */ +function newestFirst(measurements: VarroaMeasurement[]): VarroaMeasurement[] { + return [...measurements].sort( + (left, right) => right.date.localeCompare(left.date) || right.createdAt.localeCompare(left.createdAt), + ); +} + function nextMonthStart(start: number): number { const date = new Date(start); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1); @@ -194,57 +202,118 @@ function VarroaTrendChart({ const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range); const monthly = monthLimitsText(limits, range); + const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2; + const labelY = CHART_HEIGHT - LABEL_GAP; + return ( - - {`Milbenfall pro Tag. ${summary}`} - - {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ - monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" - }`} - - {redPath || yellowPath ? ( - - {yellowPath ? ( - - ) : null} - {redPath ? ( - - ) : null} - - ) : null} - {lastLimit ? ( +
+ + {`Milbenfall pro Tag. ${summary}`} + + {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ + monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" + }`} + + {redPath || yellowPath ? ( + + {yellowPath ? ( + + ) : null} + {redPath ? ( + + ) : null} + + ) : null} + {lastLimit ? ( + + {`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`} + + ) : null} + + + {formatDate(first.time)} + - {`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`} + {formatDate(last.time)} - ) : null} - - {points.map((p) => ( - - ))} + + {points.map((p) => ( + + ))} + + +
+ ); +} + +function LegendSwatch({ dash, width }: { dash?: string; width: number }) { + return ( + + ); } +/** 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 ( +
    +
  • + + + + Milbenfall pro Tag +
  • + {hasRed ? ( +
  • + + Rote Grenze +
  • + ) : null} + {hasYellow ? ( +
  • + + Gelbe Grenze +
  • + ) : null} +
+ ); +} + function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) { const note = ratingNote(measurement.rating); return ( @@ -296,9 +365,9 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) { {(measurements) => ( <> - {measurements.map((measurement) => ( + {newestFirst(measurements).map((measurement) => (
-
+

{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "} {VARROA_METHOD_LABELS[measurement.method]} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx index a2b2782..cdba00b 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx @@ -39,10 +39,33 @@ describe("EditTriggerButton", () => { expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten"); }); - it("wraps its text on a narrow screen instead of overflowing", () => { + it("names the reason in visible text next to the offline button", () => { act(() => reportRequestOutcome("network-error")); render(); - expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal"); + expect(screen.getByText("Nur mit Verbindung")).toBeVisible(); + }); + + it("is at least 44 px high", () => { + render(); + + 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(); + + 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(); + + const wrapper = screen.getByText("Nur mit Verbindung").parentElement; + expect(wrapper).toHaveClass("ml-auto", "max-w-full", "flex-wrap"); + expect(wrapper).not.toHaveClass("shrink-0"); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx index a0db854..6f5ecd5 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx @@ -1,7 +1,7 @@ "use client"; import { ComponentProps } from "react"; -import { Pencil } from "lucide-react"; +import { Pencil, WifiOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSessionMode } from "@/lib/auth/auth-context"; import { useConnectivity } from "@/lib/offline/connectivity"; @@ -11,7 +11,10 @@ import { cn } from "@/lib/utils"; * The "Bearbeiten" trigger of every record in a list: at least 40 px high on * a phone, so it can be hit with a gloved thumb. Saved records can only be * changed with a connection (edits are not queued), so offline - or in an - * offline session without a token - it is disabled and says why. + * offline session without a token - it is disabled and says why: a "no + * network" icon and a tooltip for the eye, the full sentence for screen + * readers. The visible label stays one short word so it never pushes the + * heading of its row out of the way on a phone. */ export function EditTriggerButton({ disabled, @@ -21,17 +24,30 @@ export function EditTriggerButton({ const connectivity = useConnectivity(); const sessionMode = useSessionMode(); const needsNetwork = connectivity === "offline" || sessionMode === "offline"; - return ( + const button = ( ); + if (!needsNetwork) return button; + return ( + + {button} + Nur mit Verbindung + + ); } interface DialogSubmitBarProps { diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx index 451c481..0c17ee9 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx @@ -1,12 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DashboardPage from "./page"; import { fetchAutopilotToday } from "@/lib/autopilot/api"; import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; import { fetchTasks } from "@/lib/tasks/api"; +import { fetchColonies } from "@/lib/colonies/api"; import { ApiError } from "@/lib/auth/api-client"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; import type { Task } from "@/lib/tasks/types"; vi.mock("@/lib/auth/auth-context", () => ({ @@ -26,6 +28,7 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ ...(await importOriginal()), fetchAutopilotToday: vi.fn(), })); +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), })); @@ -61,6 +64,10 @@ function renderPage() { } describe("DashboardPage – Heute", () => { + afterEach(() => { + act(() => reportRequestOutcome("reached-server")); + }); + beforeEach(() => { vi.mocked(fetchDashboardSummary).mockResolvedValue({ apiariesActive: 1, @@ -172,4 +179,77 @@ describe("DashboardPage – Heute", () => { expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary"); expect(screen.getByRole("link", { name: "Runde starten" })).not.toHaveClass("bg-primary"); }); + + it("offline: shows the „Stand“ of the cached data and switches off the decisions of the next step", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + nextStep: { + ...stepFixture({ + title: "Honigraum", + intent: "HONEY_SUPER", + action: { type: "ACCEPT", suggestionId: "s1" }, + }), + colonyId: "c1", + colonyCode: "A1", + }, + }), + ); + act(() => reportRequestOutcome("network-error")); + renderPage(); + + expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); + expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0); + expect(screen.getAllByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toHaveLength(1); + }); + + it("online: shows no „Stand“", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); + renderPage(); + + await screen.findByRole("heading", { name: "Überblick" }); + expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument(); + }); + + it("offline: task checkboxes and „Erledigt“/„Beginnen“ are off with „Nur mit Verbindung“ (task changes are not queued)", async () => { + vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch")); + vi.mocked(fetchTasks).mockResolvedValue([ + overdueTask(), + { ...overdueTask(), id: "t2", title: "Zweite Aufgabe" }, + ]); + act(() => reportRequestOutcome("network-error")); + renderPage(); + + expect(await screen.findByRole("button", { name: "Erledigt" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Beginnen" })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: /Zweite Aufgabe/ })).toBeDisabled(); + expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0); + }); + + it("gives the dashboard text links a 44 px target", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); + renderPage(); + + expect(await screen.findByRole("link", { name: "Alle Aufgaben" })).toHaveClass("min-h-11"); + }); + + it("names the colony in „Außerdem heute“ rows from the colonies list", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); + vi.mocked(fetchColonies).mockResolvedValue([{ id: "col-7", code: "V-07" }] as never); + vi.mocked(fetchTasks).mockResolvedValue([ + overdueTask(), + { + ...overdueTask(), + id: "t3", + title: "Fütterung erforderlich", + relatedType: "COLONY", + relatedId: "col-7", + }, + ]); + renderPage(); + + const row = (await screen.findByText("Fütterung erforderlich")).closest("li") as HTMLElement; + expect(await within(row).findByText("V-07")).toBeInTheDocument(); + expect(fetchColonies).toHaveBeenCalledTimes(1); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index 5a2badc..e8acecd 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -25,6 +25,7 @@ import { fetchTasks } from "@/lib/tasks/api"; import { tasksForToday } from "@/lib/tasks/today"; import { Button } from "@/components/ui/button"; import { HoneycombPattern } from "@/components/honeycomb-pattern"; +import { StandNote } from "@/components/offline/stand-note"; import { TodayPanel } from "@/components/today/today-panel"; function alertHref(alert: DashboardAlert): string | null { @@ -89,14 +90,16 @@ const ALERT_META: Record< export default function DashboardPage() { const { user } = useAuth(); const [now] = useState(() => new Date()); - const { data: summary } = useQuery({ + const summaryQuery = useQuery({ queryKey: ["dashboard", "summary"], queryFn: fetchDashboardSummary, }); - const { data: alerts, isLoading: alertsLoading } = useQuery({ + const { data: summary } = summaryQuery; + const alertsQuery = useQuery({ queryKey: ["dashboard", "alerts"], queryFn: fetchDashboardAlerts, }); + const { data: alerts, isLoading: alertsLoading } = alertsQuery; const tasksQuery = useQuery({ queryKey: ["tasks", "dashboard"], queryFn: () => fetchTasks({}), @@ -119,6 +122,14 @@ export default function DashboardPage() { (autopilotError instanceof ApiError && autopilotError.status === 403)); const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK"); const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null; + // Offline everything shown is from the cache: the oldest part sets the „Stand“. + const loadedAt = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery] + .map((query) => query.dataUpdatedAt) + .filter((updatedAt) => updatedAt > 0); + const hasStaleData = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery].some( + (query) => query.isError && query.data !== undefined, + ); + const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0; const firstName = user?.name.trim().split(/\s+/)[0]; const tiles: StatTile[] = summary @@ -161,6 +172,7 @@ export default function DashboardPage() {

+ {autopilotError && !isAutopilotSilent ? (
{href ? ( Ansehen diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx index 363bafd..ac3edbc 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx @@ -100,7 +100,7 @@ describe("NewHarvestPage", () => { it("checks the withdrawal period at the harvest date and again when it changes", async () => { vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) => at === "2026-07-01" - ? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", product: "Oxuvar" }] + ? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", lastBlockedDay: "2026-07-09", withdrawalText: "bis einschließlich 09.07. – ab 10.07. wieder erntefähig", product: "Oxuvar" }] : [], ); renderPage(); @@ -114,7 +114,7 @@ describe("NewHarvestPage", () => { await userEvent.clear(date); await userEvent.type(date, "2026-07-01"); - expect(await screen.findByText(/Wartezeit \(Oxuvar\) bis 10\.7\.2026/)).toBeInTheDocument(); + expect(await screen.findByText(/Wartezeit nach Oxuvar bis einschließlich 09\.07\. – ab 10\.07\. wieder erntefähig/)).toBeInTheDocument(); expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith( ["colony-1", "colony-2"], "2026-07-01", @@ -153,7 +153,7 @@ describe("NewHarvestPage", () => { { code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", - message: "V-01: Wartezeit nach Oxuvar endet am 20.08.2026.", + message: "V-01: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.", refs: [], }, ], @@ -165,7 +165,7 @@ describe("NewHarvestPage", () => { expect(toast.warning).toHaveBeenCalledWith( "Ernte gespeichert · 1 Warnung", - expect.objectContaining({ description: expect.stringContaining("20.08.2026") }), + expect.objectContaining({ description: expect.stringContaining("20.08.") }), ); expect(toast.success).not.toHaveBeenCalled(); expect(push).toHaveBeenCalledWith("/harvests/h-1"); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index 0604a3b..f6826eb 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -270,10 +270,8 @@ export default function NewHarvestPage() { className="size-3.5 shrink-0" aria-hidden /> - Wartezeit ({withdrawal.product}) bis{" "} - {new Date( - withdrawal.activeUntil, - ).toLocaleDateString("de-DE")} + Wartezeit nach {withdrawal.product}{" "} + {withdrawal.withdrawalText}

) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx index 663f486..c1b750b 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx @@ -7,12 +7,14 @@ import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; import AppLayout from "./layout"; const pushMock = vi.fn(); +let pathname = "/inspections/new"; vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: vi.fn(), push: pushMock }), - usePathname: () => "/inspections/new", + usePathname: () => pathname, })); let sessionMode: "online" | "offline" = "online"; +let permissions: string[] = []; vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ @@ -22,7 +24,7 @@ vi.mock("@/lib/auth/auth-context", () => ({ name: "Test Imker", roleKey: "admin", roleName: "Admin", - permissions: [], + permissions, mfaEnabled: false, hasAvatar: false, }, @@ -91,9 +93,51 @@ function expectNoScrollContainerAbove(element: HTMLElement) { describe("AppLayout", () => { beforeEach(() => { sessionMode = "online"; + pathname = "/inspections/new"; + permissions = []; pushMock.mockClear(); }); + it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => { + pathname = "/suggestions"; + permissions = ["inspections:create"]; + render( + +

Seiteninhalt

+
, + ); + + 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( + +

Seiteninhalt

+
, + ); + expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain( + "--spacing(18)", + ); + unmount(); + + pathname = "/suggestions"; + permissions = []; + render( + +

Seiteninhalt

+
, + ); + expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain( + "--spacing(18)", + ); + }); + it("shows the offline band for the signed-in user right below the header", () => { render( diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 08f7271..e609955 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -2,7 +2,7 @@ import { ReactNode, useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { Hexagon } from "lucide-react"; import { useAuth } from "@/lib/auth/auth-context"; import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; @@ -21,11 +21,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Sidebar } from "./sidebar"; import { BottomNav } from "./bottom-nav"; -import { QuickAddSheet } from "./quick-add-sheet"; +import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet"; import { PageTransition } from "./page-transition"; export default function AppLayout({ children }: { children: ReactNode }) { const router = useRouter(); + const pathname = usePathname(); const { user, isLoading, sessionMode } = useAuth(); const [isLogoutOpen, setIsLogoutOpen] = useState(false); // Focus lands here when the offline queue sheet closes and nothing else is left. @@ -57,6 +58,12 @@ export default function AppLayout({ children }: { children: ReactNode }) { return null; } + // The phone's quick-add button floats 16 px above the bottom nav and is + // 48 px high: without room under the page it would cover the last buttons. + const contentPaddingClass = hasQuickAddButton(pathname, user.permissions) + ? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]" + : "pb-(--bottom-nav-height)"; + // The page scrolls on the window. No ancestor of the page content may be a // scroll container (overflow auto/scroll/hidden): it would capture // `position: sticky` and the StickySubmit save bar would never stick. @@ -112,7 +119,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{children}
diff --git a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx index 38a45a4..055681b 100644 --- a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx +++ b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx @@ -18,6 +18,14 @@ import { QUICK_ACTIONS } from "./quick-actions"; // quick action anyway - and the header button hides with it for consistency. const FORM_ROUTE = /\/(new|edit)(\/|$)/; +/** Whether the floating/header button is there at all on this route for this user. */ +export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean { + return ( + !FORM_ROUTE.test(pathname) && + QUICK_ACTIONS.some((action) => permissions.includes(action.permission)) + ); +} + interface QuickAddSheetProps { user: Pick; /** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */ @@ -32,7 +40,7 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) { user.permissions.includes(action.permission), ); - if (FORM_ROUTE.test(pathname) || actions.length === 0) { + if (!hasQuickAddButton(pathname, user.permissions)) { return null; } diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx index ad5de61..d506efe 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx @@ -48,9 +48,13 @@ function renderTab(rules: AutomationRule[]) { ); } +/** The rule cards start collapsed: this finds one and opens it. */ async function card(title: string) { const heading = await screen.findByText(title); - return within(heading.closest("[data-slot='card']") as HTMLElement); + const element = heading.closest("[data-slot='card']") as HTMLElement; + const toggle = within(element).getByRole("button", { expanded: false }); + await userEvent.click(toggle); + return within(element); } // Tests read nested keys of what was sent; the shape is checked by the matchers. @@ -277,3 +281,43 @@ describe("other rule forms", () => { expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 }); }); }); + +describe("collapsible rule cards", () => { + it("starts every card collapsed with a summary line and opens one at a time", async () => { + const user = userEvent.setup(); + renderTab([ + rule("FEED_STATUS_CRITICAL", { notify: true, dueInDays: 2 }), + rule("WITHDRAWAL_ENDED", { notify: true }), + ]); + const first = (await screen.findByText("Futterstatus kritisch")).closest( + "[data-slot='card']", + ) as HTMLElement; + const second = (await screen.findByText("Wartezeit zu Ende")).closest("[data-slot='card']") as HTMLElement; + + expect(within(first).getAllByRole("button", { expanded: false })).toHaveLength(1); + expect(within(first).queryByLabelText("Aufgabentext")).not.toBeVisible(); + expect(within(first).getByText(/^Frist 2 Tage · meldet zusätzlich$/)).toBeInTheDocument(); + + await user.click(within(first).getByRole("button", { expanded: false })); + expect(within(first).getByLabelText("Aufgabentext")).toBeVisible(); + + await user.click(within(second).getByRole("button", { expanded: false })); + expect(within(second).getByRole("checkbox", { name: "Zusätzlich melden" })).toBeVisible(); + // Opening the second one closes the first. + expect(within(first).getByRole("button", { expanded: false })).toBeInTheDocument(); + }); + + it("edits the task text in a multi-line field and explains „Zusätzlich melden“", async () => { + renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]); + const view = await card("Futterstatus kritisch"); + expect(view.getByLabelText("Aufgabentext").tagName).toBe("TEXTAREA"); + expect(view.getByText(/Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen/)).toBeInTheDocument(); + }); + + it("makes Deaktivieren and Speichern at least 44 px high", async () => { + renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]); + const view = await card("Futterstatus kritisch"); + expect(view.getByRole("button", { name: "Deaktivieren" })).toHaveClass("min-h-11"); + expect(view.getByRole("button", { name: "Speichern" })).toHaveClass("min-h-11"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 0aef7ab..6f09f52 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -10,7 +10,6 @@ import { } from "@/lib/automation/api"; import { RULE_DESCRIPTIONS, - RULE_LABELS, SIMPLE_RULE_NOTIFY_DEFAULTS, issuesByField, } from "@/lib/automation/rule-configs"; @@ -23,12 +22,12 @@ import { errorMessage } from "@/lib/forms/error-message"; import { emptyToNull } from "@/lib/forms/empty-to-null"; import { formatGeneratedNumber } from "@/lib/automation/number-format"; import { ToggleChip } from "@/components/form/toggle-chip"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { RuleCardShell } from "./rule-card-shell"; import { hasRuleForm, SpecialRuleCard } from "./special-rule-card"; const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [ @@ -95,7 +94,15 @@ function ruleNotify(rule: AutomationRule): boolean { : (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false); } -function RuleCard({ rule }: { rule: AutomationRule }) { +function RuleCard({ + rule, + open, + onToggle, +}: { + rule: AutomationRule; + open: boolean; + onToggle: () => void; +}) { const queryClient = useQueryClient(); const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? ""); const [notify, setNotify] = useState(ruleNotify(rule)); @@ -162,14 +169,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) { } return ( - - - {RULE_LABELS[rule.triggerType]} - - {rule.active ? "Aktiv" : "Inaktiv"} - - - +

{RULE_DESCRIPTIONS[rule.triggerType]}

{numberFormatRule ? (
@@ -227,8 +227,9 @@ function RuleCard({ rule }: { rule: AutomationRule }) { ) : (
- setActionTemplate(event.target.value)} /> @@ -278,23 +279,32 @@ function RuleCard({ rule }: { rule: AutomationRule }) {

))} {!numberFormatRule ? ( - { - clearIssues("notify"); - setNotify(checked); - }} - /> +
+ { + clearIssues("notify"); + setNotify(checked); + }} + /> +

+ Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen. +

+
) : null}
-
@@ -310,12 +320,12 @@ function RuleCard({ rule }: { rule: AutomationRule }) { onConfirm={() => resetMutation.mutate()} /> ) : null} - - + ); } export function AutomationRulesTab() { + const [openRuleId, setOpenRuleId] = useState(null); const { data: rules, isLoading, isError } = useQuery({ queryKey: ["automation-rules"], queryFn: fetchAutomationRules, @@ -332,11 +342,15 @@ export function AutomationRulesTab() {
{rules?.map((rule) => - hasRuleForm(rule.triggerType) ? ( - - ) : ( - - ), + { + const open = openRuleId === rule.id; + const onToggle = () => setOpenRuleId(open ? null : rule.id); + return hasRuleForm(rule.triggerType) ? ( + + ) : ( + + ); + }, )}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx new file mode 100644 index 0000000..de1134b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx @@ -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 ( + + + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx index 5816979..02fa359 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx @@ -48,7 +48,9 @@ function renderTab(rules: AutomationRule[]) { async function card(title: string) { const heading = await screen.findByText(title); - return within(heading.closest("[data-slot='card']") as HTMLElement); + const element = heading.closest("[data-slot='card']") as HTMLElement; + await userEvent.click(within(element).getByRole("button", { expanded: false })); + return within(element); } // Tests read nested keys of what was sent; the shape is checked by the matchers. @@ -274,7 +276,7 @@ describe("a refreshed rule", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrap = (current: AutomationRule) => ( - + {}} /> ); const first = rule("PHENOLOGY", null); @@ -299,7 +301,7 @@ describe("fix round 2", () => { vi.mocked(updateAutomationRule).mockResolvedValue(phenology); const wrap = (current: AutomationRule) => ( - + {}} /> ); const view = render(wrap(phenology)); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx index 858e3f7..9252de1 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx @@ -11,7 +11,7 @@ import { AutomationTriggerType } from "@/lib/automation/types"; import { RuleDraft } from "./use-rule-draft"; export const NOTIFY_LABEL = "Zusätzlich melden"; -export const NOTIFY_HINT = "Du bekommst dann zusätzlich eine Meldung."; +export const NOTIFY_HINT = "Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen."; /** The switch every rule with a notification has. */ export function NotifyToggle({ diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx index b71b4b8..9cc0c81 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx @@ -166,7 +166,7 @@ export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormSh ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx index bc5334a..019a7b4 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx @@ -4,12 +4,11 @@ import { ComponentType, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateAutomationRule } from "@/lib/automation/api"; -import { RULE_DESCRIPTIONS, RULE_LABELS, issuesByField } from "@/lib/automation/rule-configs"; +import { RULE_DESCRIPTIONS, issuesByField } from "@/lib/automation/rule-configs"; import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types"; import { errorMessage } from "@/lib/forms/error-message"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { RuleCardShell } from "./rule-card-shell"; import { ChainForm } from "./rule-forms/chain-form"; import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form"; import { NotifyForm } from "./rule-forms/notify-form"; @@ -44,7 +43,15 @@ export function hasRuleForm(trigger: AutomationTriggerType): boolean { return trigger in RULE_FORMS; } -export function SpecialRuleCard({ rule }: { rule: AutomationRule }) { +export function SpecialRuleCard({ + rule, + open, + onToggle, +}: { + rule: AutomationRule; + open: boolean; + onToggle: () => void; +}) { const queryClient = useQueryClient(); const [issues, setIssues] = useState>({}); const Form = RULE_FORMS[rule.triggerType] as ComponentType; @@ -67,14 +74,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) { }); return ( - - - {RULE_LABELS[rule.triggerType]} - - {rule.active ? "Aktiv" : "Inaktiv"} - - - +

{RULE_DESCRIPTIONS[rule.triggerType]}

{ setIssues({}); @@ -96,7 +97,6 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) { {rule.active ? "Deaktivieren" : "Aktivieren"}
-
-
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx index 6fdf0a0..853a9cf 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx @@ -1,11 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { toast } from "sonner"; import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures"; import { Toaster } from "@/components/ui/sonner"; import { ApiError } from "@/lib/auth/api-client"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { acceptSuggestion, bulkAcceptSuggestions, @@ -116,6 +117,10 @@ Object.defineProperty(window, "matchMedia", { HTMLElement.prototype.setPointerCapture = () => undefined; HTMLElement.prototype.releasePointerCapture = () => undefined; +afterEach(() => { + act(() => reportRequestOutcome("reached-server")); +}); + beforeEach(() => { vi.clearAllMocks(); toast.dismiss(); @@ -478,4 +483,49 @@ describe("SuggestionsPage", () => { expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument(); }); + + it("offline: marks the list with its „Stand“ and switches off every decision", async () => { + const dismissed = suggestionFixture({ id: "d1", status: "DISMISSED", title: "Varroa messen" }); + mockLists(OPEN, { dismissed: [dismissed] }); + act(() => reportRequestOutcome("network-error")); + renderPage(); + + expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument(); + expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Alle übernehmen" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Verworfene (30 Tage)" })); + const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" }); + expect(within(list).getByRole("button", { name: "Wieder öffnen" })).toBeDisabled(); + expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0); + }); + + it("the „Verworfene“ chevron moves only where motion is allowed", async () => { + mockLists(OPEN); + renderPage(); + + const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" }); + const chevron = toggle.querySelector("svg"); + expect(chevron).toHaveClass("motion-safe:transition-transform"); + expect(chevron).not.toHaveClass("transition-transform"); + }); + + it("online: a failed reload with cached data shows the „Stand“ of that data", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockLists(OPEN); + render( + + + , + ); + 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(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx index e54c5bd..cbe2a8d 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx @@ -8,10 +8,13 @@ import { toast } from "sonner"; import { EmptyState } from "@/components/empty-state"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; +import { OnlineOnlyHint, StandNote } from "@/components/offline/stand-note"; import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; import { SuggestionGroup } from "@/components/suggestions/suggestion-group"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/lib/auth/auth-context"; +import { cn } from "@/lib/utils"; +import { useConnectivity } from "@/lib/offline/connectivity"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { startRound } from "@/lib/rounds/round-state"; import { @@ -35,8 +38,6 @@ const UNDO_TOAST_MS = 8000; /** The API takes 1 to 50 ids per bulk call. */ const BULK_LIMIT = 50; const TASKS_HREF = "/tasks"; -/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */ -const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!"; /** * What has to get focus once the list shows the result of a decision. @@ -196,10 +197,7 @@ export default function SuggestionsPage() { onSuccess: (_result, { suggestion }) => { requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id }); refresh(); - toast.success("Aufgabe angelegt", { - action: viewTasks, - classNames: { actionButton: TOAST_ACTION_CLASS }, - }); + toast.success("Aufgabe angelegt", { action: viewTasks }); }, onMutate: ({ suggestion }) => markPending([suggestion.id]), onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]), @@ -227,10 +225,7 @@ export default function SuggestionsPage() { onSuccess: (count) => { requestFocus({ target: "list", gone: null }); refresh(); - toast.success(taskCountText(count), { - action: viewTasks, - classNames: { actionButton: TOAST_ACTION_CLASS }, - }); + toast.success(taskCountText(count), { action: viewTasks }); }, onMutate: (ids) => markPending(ids), onSettled: (_result, _error, ids) => unmarkPending(ids), @@ -258,7 +253,6 @@ export default function SuggestionsPage() { toast("Verworfen", { duration: UNDO_TOAST_MS, action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) }, - classNames: { actionButton: TOAST_ACTION_CLASS }, }); }, onMutate: (suggestion) => markPending([suggestion.id]), @@ -293,6 +287,11 @@ export default function SuggestionsPage() { > {countText(loaded, total)}

+ Verworfene (30 Tage) @@ -403,6 +402,7 @@ function DismissedRow({ canReopen: boolean; onReopen: () => void; }) { + const isOffline = useConnectivity() === "offline"; const Icon = INTENT_ICONS[suggestion.intent]; const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · "); return ( @@ -417,10 +417,13 @@ function DismissedRow({

{canReopen ? ( - +
+ + +
) : null} ); diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 37f2881..33e37fd 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -197,6 +197,15 @@ --header-clearance: 4.5rem; /* StickySubmit with its 48px button (~4.6rem) plus breathing room. */ --sticky-submit-clearance: 5.5rem; + /* + Toasts sit above the sticky save bar. Below sm also above the bottom nav, + which carries the quick-add button (nav + 1rem + 3rem, less than the bar). + */ + --toast-offset-bottom: calc(var(--bottom-nav-height) + var(--sticky-submit-clearance) + 0.5rem); + + @variant sm { + --toast-offset-bottom: calc(var(--safe-area-bottom) + var(--sticky-submit-clearance) + 0.5rem); + } } @layer base { diff --git a/ApiCore-Frontend/src/app/layout.tsx b/ApiCore-Frontend/src/app/layout.tsx index 4d481f8..ba38b26 100644 --- a/ApiCore-Frontend/src/app/layout.tsx +++ b/ApiCore-Frontend/src/app/layout.tsx @@ -43,7 +43,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { > {children} - + diff --git a/ApiCore-Frontend/src/app/toast-offset.spec.ts b/ApiCore-Frontend/src/app/toast-offset.spec.ts new file mode 100644 index 0000000..03060b6 --- /dev/null +++ b/ApiCore-Frontend/src/app/toast-offset.spec.ts @@ -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\)/, + ); + }); +}); diff --git a/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx b/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx index f9b2dae..00020b2 100644 --- a/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx +++ b/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx @@ -1,12 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NextStepsCard } from "./next-steps-card"; -import { fetchColonyNextSteps } from "@/lib/autopilot/api"; +import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api"; import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures"; import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api"; import { ApiError } from "@/lib/auth/api-client"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { toast } from "sonner"; vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ @@ -49,6 +50,10 @@ const TASK_STEP = stepFixture({ }); describe("NextStepsCard", () => { + afterEach(() => { + act(() => reportRequestOutcome("reached-server")); + }); + beforeEach(() => { vi.mocked(fetchColonyNextSteps).mockReset(); vi.mocked(acceptSuggestion).mockReset(); @@ -233,4 +238,47 @@ describe("NextStepsCard", () => { await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement()); }); + + it("offline: decision buttons are off with „Nur mit Verbindung“ and the data says since when", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ + steps: [ + stepFixture({ + intent: "HONEY_SUPER", + title: "Honigraum", + action: { type: "ACCEPT", suggestionId: "s1" }, + }), + ], + }), + ); + act(() => reportRequestOutcome("network-error")); + renderCard(); + + expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveAttribute( + "aria-describedby", + screen.getByText("Nur mit Verbindung").id, + ); + expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it("keeps showing the cached steps when the reload fails at network level", async () => { + vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch")); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData( + colonyNextStepsKey("c1"), + colonyStepsFixture({ steps: [stepFixture({ title: "Aus dem Cache" })] }), + ); + render( + + + , + ); + + 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(); + }); }); diff --git a/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx b/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx index 78926cc..1787284 100644 --- a/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx +++ b/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react"; +import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note"; import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; import { DueChip } from "@/components/task-status"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -51,6 +52,11 @@ export function StepActions({ }: StepActionsProps) { const { action } = step; const variant = primary ? "default" : "outline"; + // Deciding needs the server; captures and links work offline. + const { isOffline, hintId, describedBy } = useOnlineOnly(); + const hasAccept = action.type === "ACCEPT" && Boolean(onAccept) && canAccept; + const hasCleanup = action.type === "CLEANUP" && Boolean(onCleanup) && canDismiss; + const hasDismiss = step.dismissible && Boolean(onDismiss) && canDismiss; return (
{action.type === "CAPTURE" ? ( @@ -66,23 +72,42 @@ export function StepActions({ ) : null} {action.type === "ACCEPT" && onAccept && canAccept ? ( - ) : null} {action.type === "CLEANUP" && onCleanup && canDismiss ? ( - ) : null} {step.dismissible && onDismiss && canDismiss ? ( - ) : null} + {hasAccept || hasCleanup || hasDismiss ? : null}
); } @@ -180,7 +205,8 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) { }); const stepActions = useStepActions(NEXT_STEPS_HEADING_ID); - if (query.isError) { + // A failed reload keeps what the cache has: the steps stay, marked with their „Stand“. + if (query.isError && !query.data) { // Offline the colony page still works; a missing hint is no news. if (isNetworkError(query.error)) return null; return ( @@ -202,6 +228,7 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) { return ( <> + reopen.mutate(step.suggestionIds) }, - classNames: { actionButton: TOAST_ACTION_CLASS }, }), onError: (error) => toast.error(suggestionErrorText(error)), onSettled: async (_result, _error, step) => { diff --git a/ApiCore-Frontend/src/components/document-upload-card.spec.tsx b/ApiCore-Frontend/src/components/document-upload-card.spec.tsx index d654070..e9ae962 100644 --- a/ApiCore-Frontend/src/components/document-upload-card.spec.tsx +++ b/ApiCore-Frontend/src/components/document-upload-card.spec.tsx @@ -69,6 +69,11 @@ describe("DocumentUploadCard", () => { vi.mocked(toast.error).mockReset(); }); + it("has an upload button of at least 44 px", () => { + renderCard(); + expect(screen.getByRole("button", { name: "Datei hochladen" })).toHaveClass("min-h-11"); + }); + it("re-encodes images before upload, PDFs unchanged", async () => { const { container } = renderCard(); const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" }); diff --git a/ApiCore-Frontend/src/components/document-upload-card.tsx b/ApiCore-Frontend/src/components/document-upload-card.tsx index 8f4cc67..d37809b 100644 --- a/ApiCore-Frontend/src/components/document-upload-card.tsx +++ b/ApiCore-Frontend/src/components/document-upload-card.tsx @@ -208,6 +208,7 @@ export function DocumentUploadCard({
))} + {!hasAllChoices ? ( +

Bitte für jede Aufgabe etwas wählen.

+ ) : null} {!canComplete ? (

„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.

) : null} +
- diff --git a/ApiCore-Frontend/src/components/suggestions/due-date-sheet.spec.tsx b/ApiCore-Frontend/src/components/suggestions/due-date-sheet.spec.tsx new file mode 100644 index 0000000..2352820 --- /dev/null +++ b/ApiCore-Frontend/src/components/suggestions/due-date-sheet.spec.tsx @@ -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( + 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(); + }); +}); diff --git a/ApiCore-Frontend/src/components/suggestions/due-date-sheet.tsx b/ApiCore-Frontend/src/components/suggestions/due-date-sheet.tsx index a5f7746..be49302 100644 --- a/ApiCore-Frontend/src/components/suggestions/due-date-sheet.tsx +++ b/ApiCore-Frontend/src/components/suggestions/due-date-sheet.tsx @@ -1,6 +1,7 @@ "use client"; import { FormEvent, useState } from "react"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -44,10 +45,11 @@ export function DueDateSheet(props: DueDateSheetProps) { function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) { const [date, setDate] = useState(initialDate); + const { isOffline, hintId, describedBy } = useOnlineOnly(); function handleSubmit(event: FormEvent) { event.preventDefault(); - if (date) onConfirm(date); + if (date && !isOffline) onConfirm(date); } return ( @@ -67,8 +69,9 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm onChange={(event) => setDate(event.target.value)} />
+
- }> diff --git a/ApiCore-Frontend/src/components/suggestions/suggestion-card.spec.tsx b/ApiCore-Frontend/src/components/suggestions/suggestion-card.spec.tsx index 24ae9ca..c7f057d 100644 --- a/ApiCore-Frontend/src/components/suggestions/suggestion-card.spec.tsx +++ b/ApiCore-Frontend/src/components/suggestions/suggestion-card.spec.tsx @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { SuggestionCard } from "./suggestion-card"; import { suggestionFixture } from "./suggestion-fixtures"; @@ -18,12 +19,31 @@ function setup(overrides = {}, handlers: Partial { + act(() => reportRequestOutcome("reached-server")); +}); + describe("SuggestionCard", () => { it("the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px", () => { setup({ mode: "PLAN" }); expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveClass("h-11"); expect(screen.getByRole("button", { name: "Verwerfen" })).toHaveClass("h-11"); - expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("size-11"); + const menu = screen.getByRole("button", { name: "Weitere Aktionen" }); + expect(menu).toHaveClass("h-11"); + expect(menu).toHaveClass("w-11"); + expect(menu).toHaveClass("shrink-0"); + }); + + it("fits the three buttons into 296 px (360 px phone minus page and card padding) and wraps instead of overflowing", () => { + setup({ mode: "PLAN" }); + + // Widths at 0.95rem text: Übernehmen ~136, Verwerfen without icon ~89, menu 44, two gaps 16 = 285. + const row = screen.getByRole("button", { name: "Übernehmen" }).parentElement as HTMLElement; + expect(row).toHaveClass("flex-wrap"); + expect(row).not.toHaveClass("flex-nowrap"); + expect(screen.getByRole("button", { name: "Verwerfen" }).querySelector("svg")).toHaveClass("hidden"); + expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("ml-auto"); + expect(screen.getByRole("article")).toHaveClass("min-w-0"); }); it("shows „Erfassen“ as a real link to the capture form for DO", () => { @@ -126,4 +146,54 @@ describe("SuggestionCard", () => { expect(heading).toHaveAttribute("tabindex", "-1"); expect(heading).toHaveAttribute("data-suggestion-heading", "s1"); }); + + describe("offline", () => { + function goOffline() { + act(() => reportRequestOutcome("network-error")); + } + + it("switches off the decisions with the hint „Nur mit Verbindung“", () => { + goOffline(); + setup({ mode: "PLAN" }); + + expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toBeDisabled(); + expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument(); + }); + + it("leaves „Erfassen“ alone: captures are queued", () => { + goOffline(); + setup({ mode: "DO", intent: "VARROA_MEASURE", colonyId: "c7" }); + + expect(screen.getByRole("link", { name: "Erfassen" })).toHaveAttribute( + "href", + "/varroa/new?colonyId=c7", + ); + }); + + it("switches off „Aufräumen“", () => { + goOffline(); + setup({ intent: "CLEANUP", mode: "DO" }, { onCleanup: vi.fn() }); + + expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled(); + }); + + it("ties the hint to the disabled buttons with aria-describedby", () => { + goOffline(); + setup({ mode: "PLAN" }); + + const hint = screen.getByText("Nur mit Verbindung"); + for (const name of ["Übernehmen", "Verwerfen", "Weitere Aktionen"]) { + expect(screen.getByRole("button", { name })).toHaveAttribute("aria-describedby", hint.id); + } + expect(hint.id).not.toBe(""); + }); + + it("shows no hint online", () => { + setup({ mode: "PLAN" }); + + expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ApiCore-Frontend/src/components/suggestions/suggestion-card.tsx b/ApiCore-Frontend/src/components/suggestions/suggestion-card.tsx index c28fd9a..fa1f47a 100644 --- a/ApiCore-Frontend/src/components/suggestions/suggestion-card.tsx +++ b/ApiCore-Frontend/src/components/suggestions/suggestion-card.tsx @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DueChip } from "@/components/task-status"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels"; import type { Suggestion } from "@/lib/suggestions/types"; import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; @@ -43,11 +44,25 @@ function menuActionOf(suggestion: Suggestion): MenuAction | null { return { label: "Als Aufgabe planen", sheetTitle: "Als Aufgabe planen", confirmLabel: "Aufgabe anlegen" }; } -function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: SuggestionCardProps) { +/** Offline, deciding is off: the server has to confirm it. */ +function MainAction({ + suggestion, + busy, + canAccept = true, + isOffline, + describedBy, + onAccept, + onCleanup, +}: SuggestionCardProps & { isOffline: boolean; describedBy?: string }) { if (suggestion.intent === "CLEANUP") { if (!onCleanup) return null; return ( - @@ -66,7 +81,13 @@ function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: } if (!canAccept) return null; return ( - @@ -79,10 +100,14 @@ export function SuggestionCard(props: SuggestionCardProps) { const [isSheetOpen, setIsSheetOpen] = useState(false); const Icon = INTENT_ICONS[suggestion.intent]; const menuAction = canAccept ? menuActionOf(suggestion) : null; + const { isOffline, hintId, describedBy } = useOnlineOnly(); + const mainAction = + suggestion.intent === "CLEANUP" ? Boolean(props.onCleanup) : suggestion.mode === "PLAN" && canAccept; + const hasOnlineOnlyButton = mainAction || canDismiss || menuAction !== null; const isUrgent = suggestion.priority === "HIGH" || suggestion.priority === "URGENT"; return ( -
+
@@ -120,16 +145,24 @@ export function SuggestionCard(props: SuggestionCardProps) {
+ {/* + 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. + */}
- + {canDismiss ? ( ) : null} @@ -138,9 +171,11 @@ export function SuggestionCard(props: SuggestionCardProps) { } @@ -155,6 +190,7 @@ export function SuggestionCard(props: SuggestionCardProps) { ) : null}
+ {hasOnlineOnlyButton ? : null} {menuAction ? ( { expect(accept[0]).toBeDisabled(); expect(accept[1]).toBeEnabled(); }); + + it("switches off „Alle übernehmen“ offline and says why", () => { + act(() => reportRequestOutcome("network-error")); + try { + setup(THREE_PLAN); + + const button = screen.getByRole("button", { name: "Alle übernehmen" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id); + } finally { + act(() => reportRequestOutcome("reached-server")); + } + }); + + it("the chevron moves only where motion is allowed", () => { + setup(THREE_PLAN); + + const chevron = screen.getByRole("button", { name: /Einzeln anzeigen/ }).querySelector("svg"); + expect(chevron).toHaveClass("motion-safe:transition-transform"); + expect(chevron).not.toHaveClass("transition-transform"); + }); }); diff --git a/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx b/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx index 173ae2d..96bcc41 100644 --- a/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx +++ b/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import Link from "next/link"; import { Check, ChevronDown, Play } from "lucide-react"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { Button, buttonVariants } from "@/components/ui/button"; import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels"; import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping"; @@ -55,6 +56,7 @@ function CombinedRow({ const round = intent === "INSPECT" ? roundTarget(suggestions) : null; const canAcceptAll = canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN"); + const { isOffline, hintId, describedBy } = useOnlineOnly(); const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id)); return ( @@ -80,7 +82,8 @@ function CombinedRow({ ) : canAcceptAll ? ( ) : null}
+ {canAcceptAll && !round ? : null} diff --git a/ApiCore-Frontend/src/components/tab-switcher.spec.tsx b/ApiCore-Frontend/src/components/tab-switcher.spec.tsx new file mode 100644 index 0000000..8165fb2 --- /dev/null +++ b/ApiCore-Frontend/src/components/tab-switcher.spec.tsx @@ -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( + {}} + />, + ); + for (const tab of screen.getAllByRole("tab")) { + expect(tab).toHaveClass("min-h-11", "sm:min-h-9"); + } + }); +}); diff --git a/ApiCore-Frontend/src/components/tab-switcher.tsx b/ApiCore-Frontend/src/components/tab-switcher.tsx index f6db144..bfbd2c7 100644 --- a/ApiCore-Frontend/src/components/tab-switcher.tsx +++ b/ApiCore-Frontend/src/components/tab-switcher.tsx @@ -20,7 +20,7 @@ export function TabSwitcher({ tabs, value, onChange }: TabSwit aria-selected={value === tab.value} onClick={() => onChange(tab.value)} className={cn( - "rounded-lg px-4 py-1.5 text-sm font-medium transition-colors", + "min-h-11 sm:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none", value === tab.value ? "bg-card text-foreground shadow-[var(--shadow-soft)]" : "text-muted-foreground hover:text-foreground", diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx index 873f154..0d50345 100644 --- a/ApiCore-Frontend/src/components/today/today-panel.tsx +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -1,11 +1,13 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react"; import { Task, TaskRelatedType } from "@/lib/tasks/types"; import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today"; import { Button } from "@/components/ui/button"; import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { SeasonChip } from "@/components/autopilot/season-chip"; import { StepActions } from "@/components/autopilot/next-steps-card"; import { useStepActions } from "@/components/autopilot/use-step-actions"; @@ -13,6 +15,7 @@ import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; import { dueChipProps } from "@/lib/autopilot/format"; import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types"; import { useAuth } from "@/lib/auth/auth-context"; +import { fetchColonies } from "@/lib/colonies/api"; import { todayLocalIsoDate } from "@/lib/dates"; import { INTENT_ICONS } from "@/lib/suggestions/labels"; import { InspectionDueCard } from "./inspection-due-card"; @@ -20,6 +23,20 @@ import { useTaskActions } from "./use-task-actions"; const TODAY_LIST_LIMIT = 5; +/** + * Code per colony id for the task rows: the colonies list is one request + * (shared with the colonies page), asked for only when a task belongs to a colony. + */ +function useColonyCodes(tasks: Task[] | undefined): ReadonlyMap { + 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 = { COLONY: "Zum Volk", APIARY: "Zum Bienenstand", @@ -50,6 +67,7 @@ export function TodayPanel({ const actions = useTaskActions(); const stepActions = useStepActions("next-step-heading"); const { hasPermission } = useAuth(); + const colonyCodes = useColonyCodes(loadedTasks); // The autopilot is enough to work with when the task list is missing. const tasks = loadedTasks ?? (autopilot ? [] : undefined); @@ -139,7 +157,7 @@ export function TodayPanel({ Alle Aufgaben @@ -166,6 +184,7 @@ export function TodayPanel({ task={task} now={now} leaving={actions.leavingIds.has(task.id)} + colonyCode={task.relatedType === "COLONY" && task.relatedId ? colonyCodes.get(task.relatedId) : undefined} onComplete={() => actions.complete(task)} /> ))} @@ -174,7 +193,7 @@ export function TodayPanel({ {hidden > 0 ? ( + {hidden} weitere für heute @@ -195,6 +214,7 @@ interface NextStepCardProps { function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) { const href = relatedHref(task); + const { isOffline, hintId, describedBy } = useOnlineOnly(); return (
- @@ -231,7 +256,8 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N size="touch" variant="outline" onClick={onStart} - disabled={isStarting || leaving} + disabled={isStarting || leaving || isOffline} + aria-describedby={describedBy} > Beginnen @@ -243,6 +269,7 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N ) : null} +
@@ -352,11 +379,14 @@ interface TodayRowProps { task: Task; now: Date; leaving: boolean; + /** Code of the colony the task is about, when known. */ + colonyCode?: string; onComplete: () => void; } -function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) { +function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) { const href = relatedHref(task); + const { isOffline, hintId, describedBy } = useOnlineOnly(); return (
  • @@ -367,7 +397,8 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) { aria-checked={leaving} aria-label={`„${task.title}“ als erledigt markieren`} className="honey-check" - disabled={leaving} + disabled={leaving || isOffline} + aria-describedby={describedBy} onClick={onComplete} > {task.title} + {colonyCode ? ( + + {colonyCode} + + ) : null} {task.status === "IN_PROGRESS" ? : null} +
    {href ? ( { + // 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(); + 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(); + 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(); + act(() => { + toast("Hinweis"); + }); + + await screen.findByText("Hinweis"); + const close = document.querySelector("[data-close-button]") as HTMLElement; + expect(close.className).toContain("size-11!"); + }); +}); diff --git a/ApiCore-Frontend/src/components/ui/sonner.tsx b/ApiCore-Frontend/src/components/ui/sonner.tsx index 9280ee5..ea4700b 100644 --- a/ApiCore-Frontend/src/components/ui/sonner.tsx +++ b/ApiCore-Frontend/src/components/ui/sonner.tsx @@ -4,6 +4,13 @@ import { useTheme } from "next-themes" import { Toaster as Sonner, type ToasterProps } from "sonner" import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" +// Toasts sit at the bottom: at the top they cover the page heading and the +// notification bell. One offset for every width: --toast-offset-bottom +// (globals.css) clears the bottom nav and the quick-add button below the app's +// sm breakpoint (640 px) and the sticky save bar everywhere. Sonner switches to +// its phone layout at 600 px, so both offsets take the same variable. +const TOAST_OFFSET = { bottom: "var(--toast-offset-bottom)", left: 16, right: 16 }; + const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme() @@ -11,6 +18,9 @@ const Toaster = ({ ...props }: ToasterProps) => { @@ -39,6 +49,10 @@ const Toaster = ({ ...props }: ToasterProps) => { toastOptions={{ classNames: { toast: "cn-toast", + // Sonner's own buttons are 24 px high; a tap target needs 44 px. + actionButton: "h-11! min-h-11! min-w-11! px-4!", + closeButton: "size-11!", + cancelButton: "h-11! min-h-11! min-w-11! px-4!", }, }} {...props} diff --git a/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx b/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx index 338bca1..f737ec2 100644 --- a/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx +++ b/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx @@ -7,7 +7,7 @@ const warnings: PlausibilityWarning[] = [ { code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", - message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.", refs: [{ type: "Colony", id: "c-1" }], }, { @@ -28,7 +28,7 @@ describe("WarningList", () => { expect(within(region).getByText("Warnung")).toBeInTheDocument(); expect(within(region).getByText("Hinweis")).toBeInTheDocument(); expect( - within(region).getByText("B3: Wartezeit nach Oxuvar endet am 20.08.2026."), + within(region).getByText("B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig."), ).toBeInTheDocument(); expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2); }); diff --git a/ApiCore-Frontend/src/lib/apiaries/offline-pack.spec.ts b/ApiCore-Frontend/src/lib/apiaries/offline-pack.spec.ts index 4cc7c19..9c5fe2a 100644 --- a/ApiCore-Frontend/src/lib/apiaries/offline-pack.spec.ts +++ b/ApiCore-Frontend/src/lib/apiaries/offline-pack.spec.ts @@ -11,6 +11,7 @@ import { getPack, isPackStale, listPacks, + livingColoniesFromPacks, loadPack, packAgeText, purgeOldPacks, @@ -308,3 +309,14 @@ describe("offline packs", () => { }); }); }); + +describe("livingColoniesFromPacks order", () => { + it("sorts colony codes naturally (V-2 before V-10)", () => { + const pack = storedPack( + offlinePack({ + colonies: [packColony("c1", "V-10"), packColony("c2", "V-2"), packColony("c3", "V-010")], + }), + ); + expect(livingColoniesFromPacks([pack]).map((colony) => colony.code)).toEqual(["V-2", "V-010", "V-10"]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/apiaries/offline-pack.ts b/ApiCore-Frontend/src/lib/apiaries/offline-pack.ts index 9428002..7361ccc 100644 --- a/ApiCore-Frontend/src/lib/apiaries/offline-pack.ts +++ b/ApiCore-Frontend/src/lib/apiaries/offline-pack.ts @@ -1,3 +1,4 @@ +import { compareColonyCodes } from "@/lib/colonies/sort"; import { ApiError, apiFetch } from "@/lib/auth/api-client"; import { isLivingColony } from "@/lib/colonies/status"; import { isNetworkError } from "@/lib/http/fetch-with-timeout"; @@ -221,9 +222,7 @@ export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColon } } } - return [...byId.values()].sort((a, b) => - a.code.localeCompare(b.code, "de", { numeric: true }), - ); + return [...byId.values()].sort((a, b) => compareColonyCodes(a.code, b.code)); } /** diff --git a/ApiCore-Frontend/src/lib/automation/rule-configs.ts b/ApiCore-Frontend/src/lib/automation/rule-configs.ts index 1563cf4..48aff2a 100644 --- a/ApiCore-Frontend/src/lib/automation/rule-configs.ts +++ b/ApiCore-Frontend/src/lib/automation/rule-configs.ts @@ -1,4 +1,4 @@ -import { AutomationTriggerType } from "./types"; +import { AutomationRule, AutomationTriggerType } from "./types"; export const RULE_LABELS: Record = { QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte", @@ -307,3 +307,20 @@ export const SIMPLE_RULE_NOTIFY_DEFAULTS: Partial 0 ? parts.join(" · ") : RULE_DESCRIPTIONS[rule.triggerType]; +} diff --git a/ApiCore-Frontend/src/lib/bottling/use-withdrawal-hints.ts b/ApiCore-Frontend/src/lib/bottling/use-withdrawal-hints.ts new file mode 100644 index 0000000..918dd05 --- /dev/null +++ b/ApiCore-Frontend/src/lib/bottling/use-withdrawal-hints.ts @@ -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 { + 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(); + 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 }; +} diff --git a/ApiCore-Frontend/src/lib/bulk/use-apiary-colonies.ts b/ApiCore-Frontend/src/lib/bulk/use-apiary-colonies.ts index 05967c4..fa1992c 100644 --- a/ApiCore-Frontend/src/lib/bulk/use-apiary-colonies.ts +++ b/ApiCore-Frontend/src/lib/bulk/use-apiary-colonies.ts @@ -1,4 +1,5 @@ "use client"; +import { compareColonyCodes } from "@/lib/colonies/sort"; import { useQuery } from "@tanstack/react-query"; import { fetchApiaries } from "@/lib/apiaries/api"; @@ -28,7 +29,7 @@ interface Choices { const BULK_CHOICES_KEY = ["bulk-choices"] as const; function byCode(a: BulkColony, b: BulkColony): number { - return a.code.localeCompare(b.code, "de", { numeric: true }); + return compareColonyCodes(a.code, b.code); } function livingOf(colonies: readonly BulkColony[]): BulkColony[] { diff --git a/ApiCore-Frontend/src/lib/calendar/types.ts b/ApiCore-Frontend/src/lib/calendar/types.ts index 053f1f5..bb1512b 100644 --- a/ApiCore-Frontend/src/lib/calendar/types.ts +++ b/ApiCore-Frontend/src/lib/calendar/types.ts @@ -16,6 +16,12 @@ export interface CalendarEvent { title: string; relatedType: string; relatedId: string; + /** Colony code, or apiary name for harvests and apiary tasks; absent for general tasks. */ + context?: string; + /** WITHDRAWAL only: the treatment product. */ + product?: string; + /** WITHDRAWAL only: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */ + detail?: string; } export interface CalendarQuery { diff --git a/ApiCore-Frontend/src/lib/colonies/api.spec.ts b/ApiCore-Frontend/src/lib/colonies/api.spec.ts new file mode 100644 index 0000000..e50f3ac --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/api.spec.ts @@ -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"]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/colonies/api.ts b/ApiCore-Frontend/src/lib/colonies/api.ts index 140cedf..29b1387 100644 --- a/ApiCore-Frontend/src/lib/colonies/api.ts +++ b/ApiCore-Frontend/src/lib/colonies/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { sortColoniesByCode } from "./sort"; import { Colony, ColonyHistory, @@ -20,7 +21,7 @@ export function fetchColonies(filter: ColonyFilter = {}): Promise { if (filter.status) params.set("status", filter.status); if (filter.apiaryId) params.set("apiaryId", filter.apiaryId); const query = params.toString(); - return apiFetch(`/colonies${query ? `?${query}` : ""}`); + return apiFetch(`/colonies${query ? `?${query}` : ""}`).then(sortColoniesByCode); } export function fetchColony(id: string): Promise { diff --git a/ApiCore-Frontend/src/lib/colonies/sort.spec.ts b/ApiCore-Frontend/src/lib/colonies/sort.spec.ts new file mode 100644 index 0000000..2f54bb6 --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/sort.spec.ts @@ -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"]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/colonies/sort.ts b/ApiCore-Frontend/src/lib/colonies/sort.ts new file mode 100644 index 0000000..2925278 --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/sort.ts @@ -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(colonies: readonly T[]): T[] { + return [...colonies].sort((left, right) => compareColonyCodes(left.code, right.code)); +} diff --git a/ApiCore-Frontend/src/lib/offline/stand.ts b/ApiCore-Frontend/src/lib/offline/stand.ts new file mode 100644 index 0000000..cddc518 --- /dev/null +++ b/ApiCore-Frontend/src/lib/offline/stand.ts @@ -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())}`; +} diff --git a/ApiCore-Frontend/src/lib/suggestions/grouping.spec.ts b/ApiCore-Frontend/src/lib/suggestions/grouping.spec.ts index 26e17e3..b8ce866 100644 --- a/ApiCore-Frontend/src/lib/suggestions/grouping.spec.ts +++ b/ApiCore-Frontend/src/lib/suggestions/grouping.spec.ts @@ -88,6 +88,14 @@ describe("roundTarget", () => { expect(target?.href).toBe("/inspections/new?colonyId=c2&round=a1"); }); + it("sorts codes with long numbers naturally (V-2 before V-010)", () => { + const target = roundTarget([ + suggestion({ id: "1", colonyId: "c1", colonyCode: "V-010" }), + suggestion({ id: "2", colonyId: "c2", colonyCode: "V-2" }), + ]); + expect(target?.colonies.map((colony) => colony.code)).toEqual(["V-2", "V-010"]); + }); + it("is null without a colony at an apiary", () => { expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull(); expect(roundTarget([])).toBeNull(); diff --git a/ApiCore-Frontend/src/lib/suggestions/grouping.ts b/ApiCore-Frontend/src/lib/suggestions/grouping.ts index c82e45e..f7059b7 100644 --- a/ApiCore-Frontend/src/lib/suggestions/grouping.ts +++ b/ApiCore-Frontend/src/lib/suggestions/grouping.ts @@ -1,3 +1,4 @@ +import { compareColonyCodes } from "@/lib/colonies/sort"; import type { Suggestion, SuggestionIntent } from "./types"; export type SuggestionRow = @@ -92,7 +93,7 @@ export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | n if (apiary === null) return null; const sorted = [...colonies.entries()] .map(([id, code]) => ({ id, code })) - .sort((a, b) => compareText(a.code, b.code)); + .sort((a, b) => compareColonyCodes(a.code, b.code)); const href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`; const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`; return { apiary, colonies: sorted, href, plainHref }; diff --git a/ApiCore-Frontend/src/lib/text/plural.spec.ts b/ApiCore-Frontend/src/lib/text/plural.spec.ts new file mode 100644 index 0000000..2fc6722 --- /dev/null +++ b/ApiCore-Frontend/src/lib/text/plural.spec.ts @@ -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"); + }); +}); diff --git a/ApiCore-Frontend/src/lib/text/plural.ts b/ApiCore-Frontend/src/lib/text/plural.ts new file mode 100644 index 0000000..651a5da --- /dev/null +++ b/ApiCore-Frontend/src/lib/text/plural.ts @@ -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"}`; +} diff --git a/ApiCore-Frontend/src/lib/treatments/types.ts b/ApiCore-Frontend/src/lib/treatments/types.ts index e5a2c58..d5bedc8 100644 --- a/ApiCore-Frontend/src/lib/treatments/types.ts +++ b/ApiCore-Frontend/src/lib/treatments/types.ts @@ -60,7 +60,12 @@ export interface UpdateTreatmentInput { export interface WithdrawalStatus { colonyId: string; + /** First day honey may be taken again (ISO). */ activeUntil: string; + /** Last blocked day, inclusive (YYYY-MM-DD). */ + lastBlockedDay: string; + /** Ready text from the server: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */ + withdrawalText: string; product: string; } diff --git a/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts b/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts index 41cc32d..8edb0b8 100644 --- a/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts +++ b/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts @@ -8,7 +8,7 @@ vi.mock("sonner", () => ({ toast: { warning: vi.fn() } })); const withdrawal: PlausibilityWarning = { code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", - message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.", refs: [], }; @@ -20,7 +20,7 @@ describe("toastWarnings", () => { expect(toast.warning).toHaveBeenCalledWith( "Gespeichert · 1 Warnung", expect.objectContaining({ - description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + description: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.", }), ); }); diff --git a/ApiCore-Frontend/src/lib/winter-checks/winter.ts b/ApiCore-Frontend/src/lib/winter-checks/winter.ts index 08518e0..c65e211 100644 --- a/ApiCore-Frontend/src/lib/winter-checks/winter.ts +++ b/ApiCore-Frontend/src/lib/winter-checks/winter.ts @@ -1,3 +1,4 @@ +import { compareColonyCodes } from "@/lib/colonies/sort"; import type { Colony } from "@/lib/colonies/types"; import { isLivingColony } from "@/lib/colonies/status"; import type { WinterCheckWithColony } from "./types"; @@ -76,7 +77,7 @@ export function buildWinterRows( return [...groups.values()] .map((group) => ({ ...group, - rows: [...group.rows].sort((a, b) => a.code.localeCompare(b.code, "de", { numeric: true })), + rows: [...group.rows].sort((a, b) => compareColonyCodes(a.code, b.code)), })) .sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, "de")); }