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