feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,994 @@
# T3 Erfassung am Stand Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ein Imker erfasst am Bienenstand mit Handschuhen und ohne Netz Durchsichten, Varroa, Behandlungen und Fütterungen schnell und korrekt. Dazu gehören Standrunde, Sammelerfassung, Offline-Standmodus mit echtem Kaltstart und eine Navigation, über die jeder Bereich erreichbar ist.
**Architecture:** Das Backend bekommt eine neue Spalte (`Inspection.broodFrames`) und schmale Lese-Endpunkte: Wettercode, Varroa-Schwelle, Behandlungs-Vorbelegung, Stand-Übersicht und Offline-Paket. Dazu kommen drei Sammel-Endpunkte. Sie schreiben nach dem Rückgrat-Muster in einer Transaktion: Datensatz, Audit und Domain-Event. Direkte Aufrufe von Automation, Kalender oder Webhooks gibt es dort nicht.
Das Frontend baut auf einer gemeinsamen Netzschicht auf: 4-s-Timeout, `ApiError.body`, Verbindungs-Store. Darüber liegen eine IndexedDB-Warteschlange in Version 3 (Besitzer, Fehlerzustand, Sammel- und Foto-Einträge) und gemeinsame Formular-Bausteine (`NumberStepper`, `RatingScale` mit Richtung, `PreviousValueHint`). Für den echten Kaltstart ohne Netz sorgen ein Service Worker mit App-Shell und Navigations-Fallback auf statische Offline-Seiten (`/offline/*?id=`) sowie eine Offline-Sitzung aus der zwischengespeicherten Identität. Tokens werden dabei nie gespeichert.
**Tech Stack:** NestJS 11, Prisma 7.9 (PostgreSQL), class-validator, Jest/Supertest · Next.js 16.3.1 App Router (vor Code `node_modules/next/dist/docs/` lesen, v. a. `01-app/02-guides/offline-support.md` und `progressive-web-apps.md`), React 19.2, TanStack Query 5, `idb` 8, Tailwind 4, shadcn/base-ui, Vitest 4 + RTL · neue devDependency `fake-indexeddb` (Frontend).
**Spec:** `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`, Abschnitt 10 „T3 Erfassung am Stand“ (zusammen mit den Eigentümer-Entscheidungen aus dem Auftrag, siehe Task-Texte).
## Global Constraints
- Sicherheit hat Priorität 1. Keine Access- oder Refresh-Tokens in IndexedDB, localStorage oder Cache Storage. Keine Stand-Koordinaten und keine Adresse im Offline-Paket. Fotos ohne EXIF/GPS. Jeder Endpunkt mit `@RequirePermission`. Die Berechtigungs-Abdeckung prüft `test/authorization-coverage.e2e-spec.ts`.
- Konsistenz ist oberstes Gesetz. Bestehende Bausteine werden wiederverwendet: `Field`, `ChoiceGroup`, `TriStateField`, `RecordSelect`, `EnumSelect`, `StickySubmit`, `emptyToNull`, `todayLocalIsoDate`/`toLocalIsoDate`/`toApiDate`, `isLivingColony`, `LivingColonySelectField`, `DocumentUploadCard`, `ConfirmDialog`, `Sheet`. Keine Kopien.
- Jedes Feld bleibt bearbeitbar, auch das Leeren. Die `null`-Regel gilt (Annahme 20: `@IsOptionalNotNull`, `skipNullProperties: false`, `emptyToNull`).
- Datenkorrektheit vor Bequemlichkeit: Beobachtungen werden nie still vorbelegt. Nur Wetter und Temperatur werden automatisch gefüllt, sichtbar und änderbar.
- ADHS-freundlich mit wenig kognitiver Last: ein Hauptknopf je Ansicht, kurze deutsche Sätze, Status nie nur über Farbe.
- Touch-Ziele mindestens 44 px (`min-h-11`/`size-11`). Sheet-Zeilen mindestens 56 px (`min-h-14`), wo angegeben.
- Alle Texte auf Deutsch, Code-Bezeichner auf Englisch.
- WCAG 2.2 AA: Radiogruppen mit Pfeiltasten, `role="status"` mit `aria-live` für Live-Werte, Kontrast über die `condition-*`-Tokens.
- TDD: erst der fehlschlagende Test, dann der Code.
- Kein `prisma db push`. Migrationen nur als Dateien unter `prisma/migrations/` und nur per `migrate deploy` anwenden.
- Agents committen nicht. Der Orchestrator committet zentral. Jeder Task endet mit grünen Checks und einer Dateiliste.
- Backend-e2e läuft gegen `.env.test`: vorher `cd ApiCore-Backend && DOTENV_CONFIG_PATH=.env.test npx prisma migrate deploy`, dann `npm run test:e2e -- <datei>`.
- Lese-, Schreib- und Fehlerpfade sind offline-fähig: ein Netzfehler landet in der Warteschlange, ein `ApiError` erscheint als Fehlertext am Formular.
## Review Focus
1. **Offline-Paket veraltet:** Ein Volk wurde nach „Für offline laden“ auf dem Server beendet oder umgesetzt. Erwartet: Die offline erfasste Durchsicht wird beim Sync abgelehnt und bleibt mit Server-Meldung in der Warteschlange. Das Paket wird danach neu geladen, und das Alter des Pakets ist sichtbar. Test in Task 15.
2. **Foto wartet, aber die Durchsicht scheitert:** Die Durchsicht wird mit 400 abgelehnt oder vom Nutzer verworfen. Erwartet: Fotos werden nie hochgeladen und nie einem falschen Datensatz zugeordnet. Sie werden zusammen mit der Durchsicht verworfen. Test in Task 10.
3. **Runde mit Volk, das mitten in der Runde umgesetzt oder beendet wird.** Erwartet: „Weiter mit …“ überspringt es, die Runde bricht nicht ab. Test in Task 14.
4. **Sammelerfassung mit einem zwischenzeitlich beendeten Volk:** Das passiert besonders bei offline eingereihten Einträgen. Erwartet: Nichts wird gespeichert, ein 400 nennt das Volk, und mit einem Tipp lässt sich ohne dieses Volk neu senden. Tests in Task 4 (Backend) und Task 13 (Warteschlange).
5. **Kaltstart offline mit abgelaufener Identität, Abmelden ohne Netz, anderer Nutzer am selben Gerät.** Erwartet: Login statt Offline-Ansicht. Die Sitzung wird beim nächsten Netzkontakt widerrufen, bevor neu angemeldet wird. Fremde Warteschlangen-Einträge werden nie unter dem neuen Konto gesendet. Tests in Task 16.
## Parallelität und Entscheidungspunkte
- Nur **Task 4** ist „abhängig von Rückgrat“, außerdem Schritt 2.9. Alle anderen Tasks sind „parallel ab sofort“. T3-interne Abhängigkeiten stehen je Task.
- Dateien, die das Rückgrat parallel ändert (`prisma/schema.prisma`, `*.module.ts`): vor jedem Edit neu lesen, nur additiv ändern.
- **K1 (entschieden 2026-10-13):** keine generierte `batchNumber` – sie ist die Chargennummer der Arzneimittelpackung und steht im Bestandsbuch; eine erfundene Nummer würde dort „FEHLT“ verdecken. Gemeinsame Nummer entsteht durch einmalige Eingabe, Zusammengehörigkeit über `bulkId` im Event-Payload. Siehe Task 4.
- **K2:** Varroa-Sammelerfassung mit `entries`, siehe Task 4.
---
### Task 1: Backend – Brutwaben `Inspection.broodFrames`
**Status:** parallel ab sofort. `inspections.service.ts` bleibt unberührt, weil die DTO per Spread übernommen wird. Ersetzt die Rückgrat-Umstellung den Spread, muss `broodFrames` dort mitgenommen werden.
**Files:**
- Modify: `ApiCore-Backend/prisma/schema.prisma` (model `Inspection`: `broodFrames Int?` hinter `colonyStrength`)
- Create: `ApiCore-Backend/prisma/migrations/20261013120000_inspection_brood_frames/migration.sql`
- Modify: `ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts`
- Test: `ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts`, `ApiCore-Backend/src/backup/backup.service.spec.ts`, `ApiCore-Backend/test/inspections.e2e-spec.ts`
**Interfaces:**
- Produces: API-Feld `broodFrames: number | null` an jeder Durchsicht. Create und Update sind optional, bei Update leert `null` das Feld.
- [ ] **Step 1: Failing tests**
- `update-dto-null-handling.spec.ts`: In der `clearable`-Liste von `UpdateInspectionDto` `'broodFrames'` ergänzen.
- `backup.service.spec.ts`: neuer Test `it('exports and restores Inspection.broodFrames')`. Der Mock `inspection.findMany` liefert `{ ...row, broodFrames: 5 }`. Erwartet: `bundle.data.inspection[0].broodFrames === 5`. Nach `importAll(bundle, 'admin-1', TRUSTED)` wird `tx.inspection.upsert` mit `create` aufgerufen, das `broodFrames: 5` enthält.
- `inspections.e2e-spec.ts`: neuer Test `it('stores, clears and validates broodFrames')`. POST `broodFrames: 4` → 201 mit `body.broodFrames === 4`. PATCH `null` → `null`. PATCH `-1` → 400. PATCH `"x"` → 400.
- [ ] **Step 2:** `cd ApiCore-Backend && npx jest src/common/validation/update-dto-null-handling.spec.ts src/backup/backup.service.spec.ts` → FAIL (die DTO kennt das Feld nicht, der Typ fehlt).
- [ ] **Step 3: Migration von Hand anlegen.** Datei `migration.sql` mit genau einer Zeile: `ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;`. Kein `migrate dev`, weil sonst parallele Rückgrat-Modelle in die Migration rutschen. Danach `npx prisma generate`. Prüfung mit `npx prisma migrate diff` in Prisma-7-Syntax (`--help`): Für `Inspection` darf keine Abweichung zwischen Migrationen und Schema übrig bleiben.
- [ ] **Step 4: DTO-Feld** `broodFrames?: number` mit `@ApiPropertyOptional({ description: 'Anzahl Brutwaben' }) @IsOptional() @IsInt() @Min(0)`. Wie `colonyStrength` ohne Obergrenze.
- [ ] **Step 5:** `DOTENV_CONFIG_PATH=.env.test npx prisma migrate deploy && npx jest src/common/validation src/backup && npm run test:e2e -- test/inspections.e2e-spec.ts` → PASS.
- [ ] **Step 6:** `npm run lint && npm run typecheck && npm run test && npm run build` → grün. Kein Commit, Übergabe an den Orchestrator.
---
### Task 2: Backend – Lese-Endpunkte (Wettercode, Varroa-Schwelle, Behandlungs-Vorbelegung) und idempotenter Dokument-Upload
**Status:** parallel ab sofort. Schritt 2.9 ist abhängig von Rückgrat-Task „Automation als Event-Handler“.
**Files:**
- Modify: `src/weather/weather.service.ts`, Test `src/weather/weather.service.spec.ts`
- Create: `src/automation/varroa-threshold.ts` und `.spec.ts`
- Create: `src/varroa/varroa-threshold.service.ts`. Modify: `src/varroa/varroa.controller.ts` (Route vor `@Get(':id')`), `src/varroa/varroa.module.ts`
- Create: `src/treatments/treatment-prefill.service.ts`, `src/treatments/dto/last-by-product-query.dto.ts`. Modify: `src/treatments/treatments.controller.ts` (Route vor `@Get(':id')`), `treatments.module.ts`
- Modify: `src/documents/documents.controller.ts` (`@Idempotent()` an `create`)
- Test: `test/varroa.e2e-spec.ts`, `test/treatments.e2e-spec.ts`, `test/documents.e2e-spec.ts`
**Interfaces:**
- Produces: `DailyWeather.weatherCode: number | null` (WMO, Open-Meteo `daily=weather_code`)
- Produces: `export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;` und `export function varroaThresholdOf(rule: Pick<AutomationRule, 'active' | 'config'> | null): number | null`
- Produces: `GET /varroa-measurements/threshold` → `{ thresholdMitesPerDay: number | null }`, Berechtigung `@RequirePermission('varroa:read', 'varroa:create')`
- Produces: `GET /treatments/last-by-product?product=<string>` → `{ treatment: TreatmentPrefill | null }`, Berechtigung `@RequirePermission('treatments:create')`:
```ts
export interface TreatmentPrefill {
sourceTreatmentId: string; sourceDate: string; product: string;
activeIngredient: string | null; withdrawalPeriodDays: number | null;
supplier: string | null; purchaseProof: string | null; prescribingVet: string | null;
}
```
- Produces: `POST /documents` akzeptiert den Header `Idempotency-Key`. Eine Wiederholung liefert dasselbe Dokument zurück.
- [ ] **Step 1: Failing tests**
- `weather.service.spec.ts`: `it('maps daily weather_code to weatherCode')` mit `[61]` → `61`. `it('weatherCode is null when Open-Meteo omits it')`.
- `varroa-threshold.spec.ts`: `null` → 10. Inaktiv → `null`. `{thresholdMitesPerDay: 7}` → 7. `'x'`, `0` und `-1` → 10.
- `varroa.e2e-spec.ts`: `it('GET threshold returns the active rule value and is not shadowed by :id')` → 200 `{ thresholdMitesPerDay: 10 }`. Nach `prisma.automationRule.update({ active: false })` → `null`. Ohne Varroa-Rechte → 403.
- `treatments.e2e-spec.ts`:
- `it('last-by-product returns the caller\'s newest treatment with that product, case-insensitive')`: zwei eigene Behandlungen „Oxuvar“, Abfrage `product=oxuvar` liefert die neuere.
- `it('last-by-product never returns another user\'s treatment')`: Staff hat eine neuere Behandlung, trotzdem kommt die eigene.
- `it('returns { treatment: null } without a match')`.
- `400 without product`.
- `403 without treatments:create`.
- `documents.e2e-spec.ts`: `it('replays an upload with the same Idempotency-Key')`. Zwei POSTs mit gleichem Key ergeben gleiche `id`, `prisma.document.count({ where: { entityId } }) === 1`.
- [ ] **Step 2:** `npx jest src/weather src/automation/varroa-threshold.spec.ts` → FAIL. Die e2e-Tests → FAIL (404 bzw. zwei Dokumente).
- [ ] **Step 3: Wetter.** Im Daily-Parameter `weather_code` ergänzen und auf `weatherCode` abbilden. Fehlt der Wert, `?? null`.
- [ ] **Step 4: Varroa-Schwelle.** `varroaThresholdOf`: Regel `null` heißt „Standardregel, aktiv“ und ergibt 10. Das entspricht `ensureDefaults`. `VarroaThresholdService.current(): Promise<{ thresholdMitesPerDay: number | null }>` liest `automationRule.findUnique({ where: { triggerType: 'VARROA_THRESHOLD' } })`.
- [ ] **Step 5: Vorbelegung.** `TreatmentPrefillService.lastByProduct(userId: string, product: string): Promise<TreatmentPrefill | null>` mit `findFirst`, `where: { userId, product: { equals: product.trim(), mode: 'insensitive' } }`, `orderBy: [{ date: 'desc' }, { createdAt: 'desc' }]` und `select` nur auf die Interface-Felder. `LastByProductQueryDto` für `product`: `@IsString() @IsNotEmpty() @MaxLength(200)`, getrimmt.
- [ ] **Step 6:** `@Idempotent()` an `DocumentsController.create`.
- [ ] **Step 7:** Tests aus Step 1 → PASS, inklusive `npm run test:e2e -- test/authorization-coverage.e2e-spec.ts`.
- [ ] **Step 8:** Lint, Typecheck, Test und Build grün. Kein Commit.
- [ ] **Step 9 (abhängig von Rückgrat „Automation als Event-Handler“):** Der VARROA_THRESHOLD-Handler bzw. `evaluateVarroaMeasurement` nutzt `varroaThresholdOf`. So gibt es nur eine Quelle für den Standardwert. Die bestehenden Automation-Specs bleiben grün.
---
### Task 3: Backend – Stand-Übersicht und Offline-Paket
**Status:** parallel ab sofort.
**Files:**
- Create: `src/apiaries/apiary-field.types.ts`, `src/apiaries/apiary-field.service.ts` und `.spec.ts`
- Modify: `src/apiaries/apiaries.controller.ts`, `src/apiaries/apiaries.module.ts` (nur Provider ergänzen)
- Test: `test/apiaries.e2e-spec.ts`
**Interfaces:**
- Produces: `GET /apiaries/:id/colony-overview` → `ColonyOverview` und `GET /apiaries/:id/offline-pack` → `OfflinePackV1`, beide `@RequirePermission('colonies:read')`, 404 bei unbekanntem Stand:
```ts
export interface ColonyOverviewRow {
id: string; code: string; status: ColonyStatus; strength: number | null;
broodStatus: ColonyCondition | null; feedStatus: ColonyCondition | null; healthStatus: ColonyCondition | null;
lastInspectionDate: string | null; openWarnings: number; openCritical: number;
}
export interface ColonyOverview { apiaryId: string; inspectionsVisible: boolean; rows: ColonyOverviewRow[] }
export interface OfflinePackV1 {
version: 1; generatedAt: string;
apiary: { id: string; name: string; location: string | null; status: ApiaryStatus };
overview: ColonyOverview;
colonies: Pick<Colony,'id'|'code'|'apiaryId'|'status'|'colonyType'|'hiveType'|'strength'|'broodStatus'|'feedStatus'|'healthStatus'|'currentQueenId'|'notes'>[];
queens: Pick<Queen,'id'|'code'|'hatchYear'|'breed'|'markingColor'|'markingNumber'|'status'>[] | null;
inspections: Omit<Inspection,'userId'>[] | null;
tasks: Pick<Task,'id'|'title'|'description'|'dueDate'|'priority'|'status'|'relatedType'|'relatedId'>[] | null;
}
```
- `ApiaryFieldService.colonyOverview(apiaryId: string, userId: string): Promise<ColonyOverview>`, `ApiaryFieldService.offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1>`.
- [ ] **Step 1: Failing unit tests** in `apiary-field.service.spec.ts` (Prisma und `PermissionResolverService` gemockt):
- `it('leaves out inspections, queens and tasks without their read permission')`: Diese Abschnitte sind `null`, `inspectionsVisible === false`, alle `lastInspectionDate === null`.
- `it('keeps at most three inspections per colony, newest first')`.
- `it('never returns coordinates, address, apiary notes or inspection userId')`: Schlüssel prüfen.
- `it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller')`: Filter `relatedType: 'COLONY'`, `read: false`, `OR: [{ userId }, { userId: null }]`.
- [ ] **Step 2: Failing e2e** in `apiaries.e2e-spec.ts`:
- `it('colony-overview lists living colonies only, sorted by code')`: V-2, V-10 und das beendete V-3 ergeben `['V-2','V-10']`.
- `it('offline-pack contains the open tasks of the apiary and its colonies')`.
- `403 without colonies:read`, `404 for an unknown apiary`.
- [ ] **Step 3:** `npx jest src/apiaries` → FAIL.
- [ ] **Step 4: Implementierung.**
- Völker mit `LIVING_COLONY_WHERE` und `apiaryId` laden, nach `code.localeCompare(b, 'de', { numeric: true })` sortieren.
- Letztes Durchsichtsdatum per `inspection.groupBy({ by: ['colonyId'], _max: { date: true } })`, nur bei `inspections:read`.
- Meldungen per `notification.groupBy` nach `relatedId` und `priority`.
- Je Volk die drei letzten Durchsichten: `findMany({ take: 3, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], omit: { userId: true } })`.
- Königinnen sind die aktuellen Königinnen der Völker (`currentQueenId`).
- Aufgaben: `status in [OPEN, IN_PROGRESS]` und entweder (APIARY, apiaryId) oder (COLONY, eine der colonyIds).
- Rechte über `permissionResolver.getPermissions(userId)` ermitteln.
- [ ] **Step 5:** Unit und `npm run test:e2e -- test/apiaries.e2e-spec.ts test/authorization-coverage.e2e-spec.ts` → PASS. Danach Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 4: Backend – Sammelerfassung `POST /treatments/bulk`, `/feedings/bulk`, `/varroa-measurements/bulk`
**Status:** abhängig von Rückgrat-Task „Produzenten-Umstellung treatments“ (Teil a), „… feedings“ (Teil b) und „… varroa“ (Teil c). Es gilt das Muster nach der Umstellung: `AuditLogService.record(entry, tx)` und `DomainEventsService.record(tx, …)` in derselben Transaktion. Kein `automation.evaluate*`, kein `calendarSync.enqueue`, kein `webhooks.dispatch`.
**Files:**
- Create: `src/common/bulk/bulk-limits.ts`, `src/common/bulk/assert-living-colonies.ts` und `.spec.ts`
- Create: `src/treatments/dto/bulk-create-treatment.dto.ts`, `src/feedings/dto/bulk-create-feeding.dto.ts`, `src/varroa/dto/bulk-create-varroa-measurement.dto.ts`
- Modify: `treatments.service.ts`, `feedings.service.ts`, `varroa.service.ts` (je `createBulk` plus eine gemeinsame private Datenaufbereitung mit `create`), dazu die drei Controller (`@Post('bulk')`, `@Idempotent()`, `@RequirePermission('<res>:create')`)
- Test: `test/treatments.e2e-spec.ts`, `test/feedings.e2e-spec.ts`, `test/varroa.e2e-spec.ts`, Service-Specs
**Interfaces:**
- Produces:
```ts
export const BULK_MAX_COLONIES = 100;
export const BULK_TRANSACTION_TIMEOUT_MS = 15_000;
export function assertLivingColonies(tx: Prisma.TransactionClient, colonyIds: readonly string[]): Promise<Map<string, { apiaryId: string }>>;
// sperrt per SELECT … FOR SHARE; wirft BadRequestException({ message: 'Einige Völker sind nicht (mehr) erfassbar', invalidColonyIds: string[] })
export class BulkCreateTreatmentDto extends OmitType(CreateTreatmentDto, ['colonyId'] as const) { colonyIds!: string[] }
export class BulkCreateFeedingDto extends OmitType(CreateFeedingDto, ['colonyId'] as const) { colonyIds!: string[] }
export class BulkVarroaEntryDto { colonyId!: string; miteCount?: number; calculatedValue?: number; assessment?: string }
export class BulkCreateVarroaMeasurementDto extends OmitType(CreateVarroaMeasurementDto, ['colonyId','miteCount','calculatedValue','assessment'] as const) { entries!: BulkVarroaEntryDto[] }
export interface BulkCreateResult<T> { bulkId: string; records: T[] }
```
- Validierung für `colonyIds`: `@IsArray() @ArrayMinSize(1) @ArrayMaxSize(BULK_MAX_COLONIES) @ArrayUnique() @IsString({ each: true }) @IsNotEmpty({ each: true })`.
- Validierung für `entries`: dieselben Grenzen, `@ValidateNested({ each: true }) @Type(() => BulkVarroaEntryDto)`, und `colonyId` muss eindeutig sein (Custom-Validator).
- Service-Signaturen: `createBulk(dto, actingUserId: string): Promise<BulkCreateResult<Treatment | Feeding | VarroaMeasurement>>`.
- Event je Datensatz: `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured` mit `payload: { colonyId, bulkId }`. Audit je Datensatz wie beim Einzel-Anlegen.
- **K1 (entschieden):** `batchNumber` wird nicht generiert. Sie gilt für alle Datensätze, weil sie ein gemeinsames Feld ist.
- **K2:** Varroa nimmt `entries`, weil jedes Volk seine eigene Milbenzahl hat. `calculatedValue` wird je Eintrag mit dem vorhandenen `deriveCalculatedValue` abgeleitet.
- [ ] **Step 1: Failing e2e** (`treatments.e2e-spec.ts`):
- `it('bulk creates one treatment per colony with the shared fields and each colony\'s apiary')`: 3 Völker an 2 Ständen, `batchNumber: 'CH-1'` → 201, `records.length === 3`, alle `batchNumber === 'CH-1'`, `apiaryId` je Volk korrekt.
- `it('bulk rejects the whole batch when one colony has ended and names it')`: ein Volk mit `DISSOLVED` → 400, `body.invalidColonyIds` enthält genau dieses Volk, `treatment.count` unverändert. Gehört zu Review Focus 4.
- `it('bulk rejects unknown ids, 0 or 101 ids and duplicates')` → 400.
- `it('bulk replays with the same Idempotency-Key')`: gleiche `bulkId`, Anzahl N statt 2N.
- `it('bulk writes one TreatmentRecorded event and one audit entry per record')`: `domainEvent.count({ where: { type: 'TreatmentRecorded', payload: { path: ['bulkId'], equals: bulkId } } }) === 3`.
- `403 without treatments:create`.
- Entsprechend für Feedings: geteilte Felder und ein beendetes Volk.
- Für Varroa: `it('bulk derives mites per day per entry')`: Einträge 30 und 6 bei `durationMinutes 4320` ergeben 10 und 2. `it('bulk rejects duplicate colonyId in entries')`.
- [ ] **Step 2: Failing unit test** (`treatments.service.spec.ts`): `it('createBulk rolls back every record when an audit write fails')`. `auditLog.record` wirft beim zweiten Aufruf, also verwirft `$transaction` den Vorgang und `createBulk` lehnt ab.
- [ ] **Step 3:** `npm run test:e2e -- test/treatments.e2e-spec.ts` → FAIL (404).
- [ ] **Step 4: Implementierung je Modul.**
- `$transaction(async (tx) => …, { timeout: BULK_TRANSACTION_TIMEOUT_MS })`. Darin zuerst `assertLivingColonies`, danach je Volk: Datensatz anlegen (Behandlung mit `apiaryId` des Volks), dann `auditLog.record(…, tx)`, dann `events.record(tx, …)`.
- `bulkId = randomUUID()`.
- Einzel- und Sammel-Anlegen nutzen dieselbe private Funktion, die `create`-Daten baut. So entstehen identische Zeilen.
- [ ] **Step 5:** e2e und Unit → PASS. Danach Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 5: Frontend – Netz-Grundlagen (Timeout, `ApiError.body`, Verbindungsstatus, QueryClient)
**Status:** parallel ab sofort.
**Files:**
- Create: `ApiCore-Frontend/src/lib/http/fetch-with-timeout.ts` und `.spec.ts`
- Create: `src/lib/offline/connectivity.ts` und `.spec.ts`
- Modify: `src/lib/auth/api-client.ts` (Test: neu `api-client.spec.ts`), `src/lib/auth/api.ts`, `src/lib/config/runtime-config.ts`, `src/app/providers.tsx`
**Interfaces:**
- Produces:
```ts
export const DEFAULT_TIMEOUT_MS = 4_000; // GET und Schreiben mit Idempotency-Key
export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000;
export const UPLOAD_TIMEOUT_MS = 30_000;
export const DOWNLOAD_TIMEOUT_MS = 60_000;
export function timeoutFor(method: string, headers: HeadersInit | undefined): number;
export function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit & { timeoutMs: number }): Promise<Response>; // AbortController + setTimeout, Caller-signal wird verknüpft; Abbruch wirft DOMException 'TimeoutError'
export function isNetworkError(error: unknown): boolean; // TypeError aus fetch oder TimeoutError/AbortError, nie ApiError
export class ApiError extends Error { readonly status: number; readonly body: unknown; }
export function apiFetchFormData<T>(path: string, formData: FormData, options?: { headers?: HeadersInit; timeoutMs?: number }): Promise<T>;
export type Connectivity = 'online' | 'offline';
export function reportRequestOutcome(outcome: 'reached-server' | 'network-error'): void;
export function getConnectivity(): Connectivity;
export function subscribeConnectivity(listener: () => void): () => void;
export function useConnectivity(): Connectivity; // useSyncExternalStore
```
- Offline gilt, wenn `navigator.onLine === false` ist, oder wenn die letzte Anfrage `network-error` war und seitdem keine den Server erreicht hat.
- QueryClient-Defaults: `queries: { networkMode: 'offlineFirst', retry: (n, e) => !(e instanceof ApiError && e.status < 500) && n < 1 }`, `mutations: { networkMode: 'always', retry: false }`.
- [ ] **Step 1: Failing tests**
- `fetch-with-timeout.spec.ts` mit Fake-Timern: `it('GET and keyed writes get 4 s, other writes 15 s')`. `it('rejects with TimeoutError after the timeout')`. `it('a timeout is a network error, an ApiError is not')`.
- `api-client.spec.ts`: `it('ApiError carries the parsed JSON body')` mit 400 `{ message, invalidColonyIds: ['c1'] }` → `error.body.invalidColonyIds`. `it('reports network-error on a failed fetch and reached-server on any HTTP answer')`.
- `connectivity.spec.ts`: `it('goes offline after a network error and online after the next server answer')`. `it('is offline while navigator.onLine is false')`.
- `providers.spec.tsx` (neu): `it('runs mutations while navigator.onLine is false')`. Das ist die Flugmodus-Regression: Die `mutationFn` wird aufgerufen.
- [ ] **Step 2:** `cd ApiCore-Frontend && npx vitest run src/lib/http src/lib/offline/connectivity.spec.ts src/lib/auth/api-client.spec.ts src/app/providers.spec.tsx` → FAIL.
- [ ] **Step 3:** Implementieren. `rawFetch`, `rawFetchFormData`, `apiFetchBlob` (`DOWNLOAD_TIMEOUT_MS`), Auth-Requests (`DEFAULT_TIMEOUT_MS`) und `fetchApiUrl` laufen über `fetchWithTimeout` und melden das Ergebnis an `reportRequestOutcome`.
- [ ] **Step 4:** Tests → PASS. `npm run lint && npm run typecheck && npm run test && npm run build` → grün. Kein Commit.
---
### Task 6: Frontend – Warteschlange v3, `useCaptureMutation`, Offline-Band und Warteschlangen-Sheet
**Status:** parallel ab sofort. T3-intern nach Task 5.
**Files:**
- Modify: `src/lib/offline/db.ts` (Test: neu `db.spec.ts` mit `fake-indexeddb`), `src/lib/offline/sync.ts` und `sync.spec.ts`
- Create: `src/lib/apiaries/field-types.ts` (Frontend-Spiegel von `ColonyOverview`, `ColonyOverviewRow` und `OfflinePackV1` aus Task 3)
- Create: `src/lib/offline/use-capture-mutation.ts` und `.spec.tsx`
- Create: `src/components/offline/offline-band.tsx` und `.spec.tsx`, `src/components/offline/pending-queue-sheet.tsx` und `.spec.tsx`
- Delete: `src/components/offline-sync-badge.tsx`. Modify: `src/app/(app)/layout.tsx`, `layout.spec.tsx`
- Modify (Umstellung auf den Hook): `src/app/(app)/{inspections,varroa,treatments,feedings,swarm-control,harvests}/new/page.tsx`
- `package.json`: devDependency `fake-indexeddb`
**Interfaces:**
- Produces:
```ts
// db.ts – DB_VERSION = 3, neue Stores nur bei oldVersion < 3 anlegen:
// 'pending-bulk' (autoIncrement), 'pending-photos' (keyPath 'id', Indizes sessionId, inspectionIdempotencyKey, ownerUserId),
// 'offline-packs' (keyPath 'apiaryId'), 'meta' (out-of-line keys)
export interface QueueError { status: number; message: string; details?: unknown; at: string }
// PendingEntry<T> += ownerUserId?: string; lastError?: QueueError
export type BulkKind = 'treatments' | 'feedings' | 'varroa';
export interface PendingBulkEntry { id: number; kind: BulkKind; input: unknown; idempotencyKey: string; queuedAt: string; ownerUserId: string; lastError?: QueueError }
export interface StoredPhoto { id: string; sessionId: string; ownerUserId: string; blob: Blob; fileName: string; createdAt: string;
state: 'draft' | 'waiting-for-inspection' | 'ready'; inspectionIdempotencyKey?: string; inspectionId?: string; uploadIdempotencyKey: string; lastError?: QueueError }
export interface StoredPack { apiaryId: string; ownerUserId: string; savedAt: string; pack: OfflinePackV1 }
export function queueInspection(input: CreateInspectionInput, idempotencyKey: string, ownerUserId: string): Promise<void>; // genauso für die 5 anderen
export interface QueueSummary { own: number; failed: number; foreign: number }
export function summarizeQueue(currentUserId: string): Promise<QueueSummary>;
export interface QueueItem { store: string; id: number | string; label: string; queuedAt: string; lastError?: QueueError; photoCount?: number }
export function listQueue(currentUserId: string): Promise<QueueItem[]>;
export function discardQueueItem(item: QueueItem): Promise<void>;
export function clearQueueOf(ownerUserId: string): Promise<void>;
// sync.ts
export interface SyncResult { synced: number; failed: number; skippedForeign: number }
export function syncPendingEntries(currentUserId: string): Promise<SyncResult>; // ersetzt syncPendingInspections
// drain(...) erhält onCreated?: (entry, result) => Promise<void>; ApiError → markFailed(store, id, QueueError)
// use-capture-mutation.ts
export type CaptureOutcome<R> = { via: 'online'; result: R } | { via: 'queued'; idempotencyKey: string };
export function useCaptureMutation<I, R>(options: {
create: (input: I, options: CreateOptions) => Promise<R>;
queue: (input: I, idempotencyKey: string, ownerUserId: string) => Promise<void>;
failureMessage: string;
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
}): { submit: (input: I) => void; isPending: boolean; submitError: string | null; idempotencyKey: string };
```
- Einträge ohne `ownerUserId` sind Altbestand und werden wie bisher gesendet.
- Einträge eines anderen Besitzers werden nie gesendet und im Sheet nur als Anzahl gezeigt: „{n} Einträge eines anderen Kontos warten“.
- Band-Texte (genau so):
- „Offline – {n} Einträge warten“, bei n = 0: „Offline – Eingaben werden gespeichert und später gesendet“
- online mit wartenden Einträgen: „{n} Einträge warten – Jetzt senden“
- bei abgelehnten Einträgen: „{f} Einträge abgelehnt – ansehen“
- [ ] **Step 1: Failing tests**
- `db.spec.ts`: `it('upgrading v2 → v3 keeps already queued inspections')`. `it('summarizeQueue separates own, failed and foreign entries')`.
- `sync.spec.ts`:
- `it('stores the server rejection on the entry and keeps it')`: `lastError.status === 400`, kein `remove`.
- `it('never sends entries queued by another user')`: `skippedForeign === 1`, `createInspection` nicht aufgerufen.
- `it('passes the server result to onCreated')`.
- `use-capture-mutation.spec.tsx`:
- `it('queues on a network error with the form\'s key and the current user')`.
- `it('shows the ApiError message and does not queue')`.
- `it('queues even while navigator.onLine is false')`.
- `offline-band.spec.tsx`:
- `it('announces „Offline – 2 Einträge warten" in a status region')`.
- `it('is hidden online with an empty queue')`.
- `it('„Jetzt senden" starts syncPendingEntries')`.
- `it('its button is at least 44 px')` (Klasse `min-h-11`).
- `pending-queue-sheet.spec.tsx`: `it('lists a rejected entry with the server message and can discard it after confirmation')`.
- [ ] **Step 2:** `npx vitest run src/lib/offline src/components/offline` → FAIL.
- [ ] **Step 3:** db v3, sync und Hook implementieren. Die sechs Erfassungsformulare auf `useCaptureMutation` umstellen. Verhalten und Texte bleiben gleich, die bestehenden Seiten-Specs bleiben grün.
- [ ] **Step 4:** Band unter dem Header im Layout einbauen (statt Badge). Auto-Sync bei `online` und beim Wechsel der Verbindung auf online, Zählung alle 15 s.
- [ ] **Step 5:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 7: Frontend – Navigation („Mehr“, „+ Neu“-Sheet, Zurück-Link)
**Status:** parallel ab sofort.
**Files:**
- Create: `src/app/(app)/nav-config.ts` und `.spec.ts`, `src/app/(app)/quick-actions.ts`, `src/app/(app)/more-sheet.tsx` und `.spec.tsx`, `src/app/(app)/quick-add-sheet.tsx` und `.spec.tsx` (ersetzt `quick-add-menu.tsx` und dessen Spec)
- Modify: `sidebar.tsx`, `bottom-nav.tsx`, `layout.tsx` (Header-Knopf „+ Neu“ ab `sm`)
- Create: `src/components/form/form-back-link.tsx` und `.spec.tsx`, `src/lib/forms/back-target.ts`. Modify: `src/lib/forms/use-form-draft.ts` (`flush`)
- Modify: alle 17 `src/app/(app)/**/new/page.tsx` (FormBackLink über dem PageHeader). Bei `harvests/new` das PageHeader-Icon `Scissors` wie in der Navigation.
**Interfaces:**
- Produces:
```ts
export interface NavItem { label: string; shortLabel?: string; href: string; icon: LucideIcon; permission?: string | string[] }
export interface NavGroup { label: string | null; items: NavItem[] }
export const NAV_GROUPS: NavGroup[]; export const ADMIN_GROUP: NavGroup;
export function visibleNavGroups(user: Pick<AuthUser, 'permissions'>): NavGroup[];
export const BOTTOM_NAV_HREFS: readonly ['/dashboard', '/colonies', '/tasks', '/search'];
export interface QuickAction { label: string; href: string; icon: LucideIcon; permission: string }
export const QUICK_ACTIONS: readonly QuickAction[];
// Durchsicht ClipboardCheck inspections:create · Varroa-Messung Bug varroa:create · Schwarmkontrolle Sprout swarm-control:create
// Behandlung Syringe treatments:create · Fütterung Utensils feedings:create · Aufgabe ListTodo tasks:create · Ernte Scissors harvests:create
export interface FormBackLinkProps { href: string; label: string; dirty: boolean; draft?: { flush: () => void } }
export function captureBackTarget(params: URLSearchParams, fallback: { href: string; label: string }): { href: string; label: string };
// colonyId → `/colonies/${encodeURIComponent(id)}` „Zum Volk"; round → `/apiaries/${encodeURIComponent(round)}` „Zum Stand"; sonst fallback
// FormDraft<T> += flush(): void // schreibt sofort, wenn dirty und kein Entwurf offen
```
- Bottom-Nav: Start, Völker, Aufgaben, Suche und „Mehr“. „Mehr“ öffnet ein Sheet (`side="bottom"`) mit allen sichtbaren Gruppen, Zeilen `min-h-14`. Sidebar, Bottom-Nav und Sheet lesen nur aus `nav-config.ts`.
- „+ Neu“: dasselbe Sheet mobil (FAB) und am Desktop (Header-Knopf). Icons `size-6`, Zeilen `min-h-14`, nach Berechtigung gefiltert, auf `/new` und `/edit` ausgeblendet (bestehende `FORM_ROUTE`-Regel).
- FormBackLink-Dialog:
- mit Entwurf: „Formular verlassen? Deine Eingaben bleiben als Entwurf auf diesem Gerät.“
- ohne Entwurf: „… Deine Eingaben gehen verloren.“
- Knöpfe: „Weiter bearbeiten“ und „Verlassen“.
- Fallback-Ziele:
- apiaries → /apiaries, bottling → /bottling, colonies → /colonies, equipment → /equipment, extractions → /extractions, harvests → /harvests, production-batches → /production-batches, products → /products, queens → /queens, raw-materials → /raw-materials, tasks → /tasks
- settings/label-templates → /settings/automation
- inspections, varroa, treatments, feedings und swarm-control → `captureBackTarget(params, { href: '/dashboard', label: 'Zur Übersicht' })`
- [ ] **Step 1: Failing tests**
- `nav-config.spec.ts`: `it('every sidebar area is reachable on a phone via bottom nav or „Mehr")` für einen Nutzer mit allen Rechten. `it('admin entries need their permission')`.
- `more-sheet.spec.tsx`: `it('„Mehr" opens a sheet listing Honigchargen with 56 px rows')`.
- `quick-add-sheet.spec.tsx`:
- `it('lists actions with icons as links')`.
- `it('hides Behandlung without treatments:create')`.
- `it('is not rendered on /inspections/new')`.
- `it('offers a header button on desktop')`.
- `form-back-link.spec.tsx`:
- `it('is a plain link when nothing was typed')`.
- `it('asks before leaving and flushes the draft')`.
- `it('warns that input is lost when there is no draft')`.
- `use-form-draft` Spec: `it('flush writes immediately')`.
- `inspections/new/page.spec.tsx`: `it('links back to the colony it was opened from')`.
- [ ] **Step 2:** `npx vitest run "src/app/(app)" src/components/form src/lib/forms` → FAIL.
- [ ] **Step 3:** Implementieren und alle 17 Seiten einbinden.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 8: Frontend – Formular-Bausteine (Plus/Minus-Feld, Skalenrichtung und Töne, „Letztes Mal“)
**Status:** parallel ab sofort.
**Files:**
- Create: `src/components/form/number-stepper.tsx` und `.spec.tsx`, `src/components/form/previous-value-hint.tsx` und `.spec.tsx`, `src/lib/forms/rating.ts` und `.spec.ts`, `src/components/form/rating-scale.spec.tsx`
- Modify: `src/components/form/rating-scale.tsx`, `choice-group.tsx` (`ChoiceOption.selectedClassName?: string`), `tri-state-field.tsx` und Spec
**Interfaces:**
- Produces:
```ts
export interface NumberStepperProps { id: string; value: string; onChange: (value: string) => void; min?: number /* 0 */; step?: number /* 1 */;
unitLabel: string; decrementLabel: string; incrementLabel: string; describedBy?: string; invalid?: boolean }
// "" = nicht gezählt; „+" auf "" → String(min + step); „−" bei min deaktiviert; Text-Knopf „leeren" (min-h-11) bei Wert ≠ ""; Eingabefeld bleibt tippbar
export type RatingDirection = 'higher-is-better' | 'lower-is-better';
export function toDisplayRating(stored: number | null, direction: RatingDirection): number | null; // lower: 6 - stored
export function toStoredRating(display: number | null, direction: RatingDirection): number | null;
export const RATING_TONE_CLASS: Record<1 | 2 | 3 | 4 | 5, string>; // 1 critical, 2 weak, 3 average, 4 good, 5 excellent (condition-*-Tokens)
// RatingScaleProps += direction?: RatingDirection (Default 'higher-is-better'); lowLabel/highLabel = Anzeige-Enden 1 / 5
export interface PreviousValueHintProps { fieldLabel: string; valueLabel: string; onTakeOver: () => void }
// „Letztes Mal: {valueLabel} · übernehmen" (Knopf min-h-11, sr-only-Zusatz „– {fieldLabel}")
// TriStateFieldProps += previous?: { valueLabel: string; onTakeOver: () => void } | null
```
- Alle Skalen färben die gewählte Stufe nach ihrem Anzeigewert ein, damit die Töne überall gleich sind.
- [ ] **Step 1: Failing tests**
- `number-stepper.spec.tsx`:
- `it('„+" from empty gives 1')`.
- `it('„−" is disabled at 0')`.
- `it('typing 7 then „+" gives 8')`.
- `it('„leeren" clears to empty')`.
- `it('buttons are named and 44 px')`.
- `rating.spec.ts`: Hin- und Rückrechnung für 1 bis 5 und `null`.
- `rating-scale.spec.tsx`:
- `it('lower-is-better shows stored 5 as display 1 on the left')`.
- `it('tapping display 5 stores 1')`.
- `it('tapping the chosen value again clears to null')`.
- `it('the selected option carries the tone of its display value')`.
- `previous-value-hint.spec.tsx`: `it('announces and takes the value over')`.
- `tri-state-field.spec.tsx`: `it('shows „Letztes Mal: Ja · übernehmen" and takes it over')`.
- [ ] **Step 2:** `npx vitest run src/components/form src/lib/forms/rating.spec.ts` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 9: Frontend – Durchsicht neu (Hinweise „Letztes Mal“, Wetter automatisch, Waben-Stepper, Schwarmstimmung umgedreht)
**Status:** parallel ab sofort. T3-intern nach den Tasks 1, 2 (Felder `broodFrames` und `weatherCode`; fehlt `weatherCode`, wird es als `null` behandelt), 6 und 8.
**Files:**
- Modify: `src/lib/inspections/types.ts` (`broodFrames` in Inspection, Create und Update), `src/lib/weather/types.ts` (`weatherCode: number | null`)
- Create: `src/lib/weather/describe.ts` und `.spec.ts`, `src/lib/inspections/takeover.ts` und `.spec.ts`, `src/lib/inspections/use-last-inspection.ts`
- Modify: `src/app/(app)/inspections/new/inspection-form-values.ts` und Spec, `inspections/new/page.tsx` und Spec, `colonies/[id]/inspection-edit-dialog.tsx` und Spec, `colonies/[id]/colony-inspections.tsx`
**Interfaces:**
- Consumes: `NumberStepper`, `RatingScale({ direction })`, `PreviousValueHint`, `TriStateField({ previous })` (Task 8). `useCaptureMutation` (Task 6). `fetchApiaryWeather` (vorhanden, Query-Key `["apiaries", apiaryId, "weather"]`).
- Produces:
```ts
export function describeWeatherCode(code: number | null | undefined): string | null;
// 0 Sonnig · 1 Überwiegend sonnig · 2 Teilweise bewölkt · 3 Bedeckt · 45/48 Nebel · 51–57 Nieselregen · 61–67 Regen
// 71–77 Schnee · 80–82 Regenschauer · 85–86 Schneeschauer · 95–99 Gewitter · sonst null
export interface WeatherPrefill { weather: string; temperature: string }
export function weatherPrefillFor(daily: readonly DailyWeather[], isoDate: string): WeatherPrefill | null;
// weather = "{Beschreibung}, {Niederschlag} mm, Wind {Wind} km/h" ohne null-Teile; temperature = tempMaxC auf 1 Nachkommastelle ("21.5"); null außerhalb des Fensters
export const TAKEOVER_FIELDS: readonly ['queenSeen','eggsPresent','openBroodPresent','cappedBroodPresent','queenCellsPresent',
'broodPattern','broodFrames','colonyStrength','gentleness','combBehavior','swarmMood','feedStatus','honeySuperStatus'];
export type TakeoverField = (typeof TAKEOVER_FIELDS)[number];
export function previousValueOf(last: Inspection, field: TakeoverField): InspectionFormValues[TakeoverField] | null;
export function previousLabel(field: TakeoverField, value: NonNullable<InspectionFormValues[TakeoverField]>): string; // „Ja", „3 von 5", „8 Waben", „Ausreichend"
export function takeOverAll(values: InspectionFormValues, last: Inspection): { values: InspectionFormValues; taken: TakeoverField[] }; // nur leere Felder
export function useLastInspection(colonyId: string): { inspection: Inspection | null; isLoading: boolean }; // Key ["colonies", colonyId, "inspections"]
// InspectionFormValues += broodFrames: string; weatherAuto: boolean (weatherAuto=true: Wetter stammt aus der Vorbelegung und darf bei Datum/Volk-Wechsel ersetzt werden)
```
- Seitenaufbau:
- `NewInspectionPage` liest die Parameter und rendert `<InspectionCaptureForm key={`${colonyId}:${round ?? ""}`} … />`. Der Remount setzt Zustand und Idempotency-Key zurück.
- Reihenfolge: Volk & Datum, darunter „Letzte Durchsicht: {Datum} · vor {n} Tagen“ und der Knopf „Alles wie letztes Mal“ (outline, min-h-11).
- Danach: Königin & Brut mit Brutwaben-Stepper, Stärke & Verhalten (Volksstärke als Stepper mit „besetzte Waben“, Schwarmstimmung mit `direction="lower-is-better"`, `lowLabel="stark"`, `highLabel="ruhig"`), Futter, Wetter, Weitere Angaben.
- Wetter steht in einem eigenen, offenen Abschnitt mit dem Hinweis „Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar“.
- Auf neuen Durchsichten gibt es kein Feld „Brutmenge“.
- Im Bearbeiten-Dialog erscheint „Brutmenge (alt)“ nur, wenn `inspection.broodAmount !== null` (aus dem Prop, nicht aus dem Formularwert). Hinweis dazu: „Freitext aus früheren Durchsichten – neue Durchsichten zählen Brutwaben“.
- „Alles wie letztes Mal“ zeigt danach den Toast „{n} Werte übernommen“ mit der Aktion „Rückgängig“.
- [ ] **Step 1: Failing tests**
- `describe.spec.ts`: Codes 0, 3, 61, 95 und unbekannt. `weatherPrefillFor` liefert Eintrag, Text und Temperatur für das Datum, `null` außerhalb des Fensters.
- `takeover.spec.ts`:
- `it('takes over only empty fields')`.
- `it('never overwrites a typed value')`.
- `it('skips fields that were null last time')`.
- `it('keeps a legacy free-text feedStatus verbatim')`.
- `inspection-form-values.spec.ts`: `broodFrames` '' → `null`, '4' → 4. Bei neuen Durchsichten geht `broodAmount: null` raus.
- `inspections/new/page.spec.tsx`:
- `it('nothing is preselected from the last inspection')`: Nach dem Laden stehen alle Ja/Nein-Felder auf „Nicht geprüft“, Skalen und Stepper sind leer.
- `it('„Letztes Mal: Ja · übernehmen" sets queenSeen on tap')`.
- `it('„Alles wie letztes Mal" fills only untouched fields and can be undone')`.
- `it('fills weather and temperature from the apiary weather for the chosen date')`.
- `it('re-fills auto weather when the date changes but never overwrites typed weather')`.
- `it('swarm mood: tapping display 5 („ruhig") sends swarmMood 1')`.
- `it('brood frames stepper sends broodFrames 5')`.
- `it('has no Brutmenge field')`.
- `inspection-edit-dialog.spec.tsx`:
- `it('shows Brutmenge (alt) only when the inspection has one')`.
- `it('clearing broodFrames sends null')`.
- `it('stored swarmMood 5 shows display 1 selected')`.
- [ ] **Step 2:** `npx vitest run src/lib/weather src/lib/inspections "src/app/(app)/inspections" "src/app/(app)/colonies"` → FAIL.
- [ ] **Step 3:** Implementieren. In der Durchsichtsliste den Zusatz „· {n} Brutwaben“ ergänzen. Der PDF-Export „Schwarmtrieb 1–5“ bleibt unverändert, weil seine Spalte die gespeicherte Bedeutung trägt.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 10: Frontend – Foto in der Durchsicht (Kamera, offline, ohne Metadaten)
**Status:** parallel ab sofort. T3-intern nach den Tasks 2 (idempotenter Upload), 6 und 9.
**Files:**
- Create: `src/lib/documents/limits.ts`, `src/lib/photos/reencode.ts` und `.spec.ts`, `src/lib/photos/store.ts` und `.spec.ts`, `src/components/photos/photo-capture-field.tsx` und `.spec.tsx`
- Modify: `src/lib/documents/api.ts`, `src/components/document-upload-card.tsx` (Grenze aus `limits.ts`; Bilder laufen durch `preparePhoto`), `src/lib/offline/sync.ts` und Spec, `src/components/offline/pending-queue-sheet.tsx`, `inspection-form-values.ts` (`photoSessionId`), `inspections/new/page.tsx`, `colonies/[id]/inspection-edit-dialog.tsx` (Abschnitt „Fotos“ mit `<DocumentUploadCard entityType="INSPECTION" entityId={inspection.id} />`), `src/app/(app)/layout.tsx` (beim Start `purgeStaleDraftPhotos`)
**Interfaces:**
- Produces:
```ts
export const MAX_DOCUMENT_UPLOAD_BYTES = 10 * 1024 * 1024; // = MAX_FILE_SIZE_BYTES in documents.controller.ts
export const PHOTO_MAX_EDGE_PX = 2560, PHOTO_JPEG_QUALITY = 0.85, PHOTO_FALLBACK_QUALITY = 0.7, PHOTOS_PER_INSPECTION_MAX = 5;
export interface ImageEncoder { encode(file: Blob, maxEdgePx: number, quality: number): Promise<Blob> }
export const canvasImageEncoder: ImageEncoder; // createImageBitmap(file, { imageOrientation: 'from-image' }) → canvas → toBlob('image/jpeg')
export class PhotoTooLargeError extends Error {} export class PhotoMetadataError extends Error {}
export function containsExifOrGps(bytes: Uint8Array): boolean; // JPEG APP1 „Exif\0\0" oder XMP „http://ns.adobe.com/xap/"
export function preparePhoto(file: Blob, encoder?: ImageEncoder): Promise<Blob>; // Rückfall-Qualität, sonst TooLarge; Metadaten im Ergebnis → PhotoMetadataError (nie hochladen)
export function uploadDocument(entityType: DocumentEntityType, entityId: string, file: Blob, options?: CreateOptions & { fileName?: string }): Promise<DocumentSummary>;
export function addDraftPhoto(p: { sessionId: string; ownerUserId: string; blob: Blob; date: string }): Promise<StoredPhoto>; // fileName `durchsicht-${date}-${n}.jpg`
export function listSessionPhotos(sessionId: string): Promise<StoredPhoto[]>;
export function removePhoto(id: string): Promise<void>;
export function discardSessionPhotos(sessionId: string): Promise<void>;
export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise<void>; // → ready
export function attachSessionToQueuedInspection(sessionId: string, inspectionIdempotencyKey: string): Promise<void>; // → waiting-for-inspection
export function resolveQueuedInspection(inspectionIdempotencyKey: string, inspectionId: string): Promise<void>; // → ready
export function discardPhotosForInspection(inspectionIdempotencyKey: string): Promise<void>;
export function purgeStaleDraftPhotos(now: number): Promise<number>; // Entwürfe älter als 3 Tage
export function uploadReadyPhotos(currentUserId: string, upload?: typeof uploadDocument): Promise<SyncResult>;
```
- Ablauf:
- Erfassungsknopf: „Foto aufnehmen“ (size touch, Kamera-Icon) auf einem versteckten `<input type="file" accept="image/*" capture="environment">`.
- Nur sichtbar mit `documents:upload`.
- Das Foto wird sofort per `addDraftPhoto` gespeichert. `photoSessionId` steht in den Entwurfswerten, deshalb kommen Fotos mit einem wiederhergestellten Entwurf zurück. „Entwurf verwerfen“ löscht auch die Fotos.
- Nach dem Speichern: Online `attachSessionToInspection` und `uploadReadyPhotos` im Hintergrund. In der Warteschlange `attachSessionToQueuedInspection`.
- Sync: Die Durchsichten-Drain ruft `onCreated` mit `resolveQueuedInspection` auf. Fotos werden erst hochgeladen, wenn alle Datensatz-Drains fertig sind.
- Upload mit dem eigenen Key des Fotos (`uploadIdempotencyKey`) an `INSPECTION/<server id>`.
- [ ] **Step 1: Failing tests**
- `reencode.spec.ts`:
- `it('detects an EXIF APP1 segment')` und `it('detects XMP')` mit Byte-Fixtures.
- `it('a clean JPEG passes')`.
- `it('falls back to quality 0.7 when the first result is over 10 MB')` mit Fake-Encoder: 11 MB, dann 2 MB.
- `it('throws PhotoTooLargeError when still too large')`.
- `it('throws PhotoMetadataError and never returns a blob that still has EXIF')`.
- `store.spec.ts` (fake-indexeddb):
- `it('draft → waiting → ready after the queued inspection is created')`.
- `it('photos of an inspection the server rejected are never uploaded')`: Die Durchsicht bekommt 400 und behält ihren `lastError`, `uploadReadyPhotos` lädt 0 hoch. Gehört zu Review Focus 2.
- `it('discarding the inspection discards its photos')`.
- `it('uploads with the photo\'s own Idempotency-Key to INSPECTION/<server id> and deletes it afterwards')`.
- `it('keeps a photo with lastError on 413')`.
- `photo-capture-field.spec.tsx`:
- `it('input accepts image/* with capture="environment"')`.
- `it('is hidden without documents:upload')`.
- `it('blocks a 6th photo with a message')`.
- `it('removing a photo deletes it from the store')`.
- `sync.spec.ts`: `it('uploads photos only after their inspection synced in the same run')`.
- `document-upload-card` Spec: `it('re-encodes images before upload, PDFs unchanged')`.
- [ ] **Step 2:** `npx vitest run src/lib/photos src/components/photos src/lib/offline` → FAIL.
- [ ] **Step 3:** Implementieren. Im Warteschlangen-Sheet stehen an der Durchsicht „+ {n} Fotos“. Fehlgeschlagene Fotos haben „Erneut senden“ und „Verwerfen“.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Manuelle Prüfung am Handy: Ein hochgeladenes Foto enthält keine GPS-Daten (z. B. per `exiftool` auf den Download). Kein Commit.
---
**Carry-forward (Orchestrator, aus Reviews von Task 6):**
- Jeder Hintergrund-Upload (auch `uploadReadyPhotos` nach einem Online-Speichern) läuft innerhalb von `runSync` bzw. respektiert Gate und Generation aus `src/lib/offline/sync.ts` – sonst entgeht er `cancelSync()`.
- `onCreated` je Eintrag fehlerisoliert behandeln (ein werfendes `onCreated` darf nicht den ganzen Lauf verwerfen).
- Test „lädt Fotos erst nach der Durchsicht im selben Lauf hoch“ prüft, dass `resolveQueuedInspection` die Server-ID bekommt, bevor der Eintrag entfernt wird.
### Task 11: Frontend – Varroa (Zeitraum in Tagen, Milben/Tag mit Ampel, Trend mit Schwellenlinie)
**Status:** parallel ab sofort. T3-intern nach den Tasks 2 und 6.
**Files:**
- Create: `src/lib/varroa/period.ts` und `.spec.ts`, `src/lib/varroa/rate.ts` und `.spec.ts`, `src/components/varroa/varroa-period-field.tsx`, `src/components/varroa/varroa-rate-light.tsx`
- Modify: `src/lib/varroa/api.ts` (`fetchVarroaThreshold`), `varroa/new/varroa-form-values.ts`, `varroa/new/page.tsx` und Spec, `colonies/[id]/varroa-edit-dialog.tsx` und Spec, `colonies/[id]/colony-varroa.tsx` (Test: neu `colony-varroa.spec.tsx`)
**Interfaces:**
- Produces:
```ts
export const MINUTES_PER_DAY = 1440; export const PERIOD_DAY_CHOICES = [1, 2, 3, 7] as const;
export function daysToMinutes(days: number): number; // Math.round(days * 1440)
export function periodChoiceOf(minutes: string): 1 | 2 | 3 | 7 | 'other' | null;
export function formatDays(minutes: number): string; // „1,5 Tage"
export const VARROA_WARN_FRACTION = 0.5;
export function mitesPerDay(miteCount: number | null, durationMinutes: number | null): number | null; // wie das Backend
export type VarroaLight = 'green' | 'yellow' | 'red';
export function varroaLight(rate: number | null, threshold: number | null): VarroaLight | null; // rot ≥ Schwelle, gelb ≥ Schwelle·0,5
export function fetchVarroaThreshold(): Promise<{ thresholdMitesPerDay: number | null }>; // Key ["varroa","threshold"], staleTime 1 h
```
- Feldverhalten:
- Natürlicher Milbenfall: Chips „1 Tag“, „2 Tage“, „3 Tage“, „7 Tage“, „anders“. „anders“ öffnet ein Feld „Tage“ (`inputMode="decimal"`).
- Kein Chip ist vorgewählt.
- Gespeichert wird weiter `durationMinutes`.
- Ein Altwert, der kein ganzes Vielfaches eines Tags ist, erscheint als „anders: {Tage} ({n} Minuten)“ und bleibt beim Speichern exakt erhalten.
- Die übrigen Methoden behalten das Minutenfeld.
- Ampeltexte (live, `role="status"`):
- „{rate} Milben/Tag – unter der Schwelle ({t}/Tag)“
- „… – nahe an der Schwelle“
- „… – über der Schwelle, Behandlung prüfen“
- Ohne Schwelle nur die Rate.
- Trend: nur `NATURAL_MITE_FALL` mit `calculatedValue`, zeitlich skaliert, gestrichelte Linie „Schwelle {t}/Tag“. `<title>` und `<desc>` mit der Zusammenfassung „Letzte Messung {rate} am {Datum}, Schwelle {t}“. Bei weniger als 2 Punkten nur der Wert als Text. Beim Bau den `dataviz`-Skill laden.
- [ ] **Step 1: Failing tests**
- `period.spec.ts`: 3 Tage → 4320. `periodChoiceOf('4320') === 3`. `'2000'` → `'other'`. `''` → `null`. 1,5 Tage → 2160.
- `rate.spec.ts`: 30 Milben über 4320 Minuten → 10. Ohne Dauer `null`. Die Ampel ist rot bei 10/10, gelb bei 5/10, grün bei 4,9/10 und `null` ohne Schwelle.
- `varroa/new/page.spec.tsx`:
- `it('„3 Tage" sends durationMinutes 4320')`.
- `it('„anders" 5 days sends 7200')`.
- `it('shows „10,0 Milben/Tag – über der Schwelle" live')`.
- `it('rate without traffic light when the rule is inactive')`.
- `it('other methods keep the minutes field')`.
- `varroa-edit-dialog.spec.tsx`: `it('a stored 2000 minutes is sent back unchanged')`.
- `colony-varroa.spec.tsx`:
- `it('the chart ignores powdered sugar counts')`.
- `it('draws the threshold line')`.
- `it('has an accessible summary')`.
- [ ] **Step 2:** `npx vitest run src/lib/varroa src/components/varroa "src/app/(app)/varroa" "src/app/(app)/colonies"` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 12: Frontend – Behandlung: Vorbelegung aus der eigenen letzten Behandlung
**Status:** parallel ab sofort. T3-intern nach den Tasks 2 und 6.
**Files:**
- Modify: `src/lib/treatments/types.ts` (`TreatmentPrefill` wie in Task 2), `src/lib/treatments/api.ts`
- Create: `src/lib/treatments/prefill.ts` und `.spec.ts`, `src/lib/treatments/use-treatment-prefill.ts`
- Modify: `treatments/new/page.tsx` und Spec, `treatments/new/treatment-form-values.ts`
**Interfaces:**
- Produces:
```ts
export function fetchTreatmentPrefill(product: string): Promise<TreatmentPrefill | null>; // entpackt { treatment }
export const PREFILL_FIELDS: readonly ['activeIngredient','withdrawalPeriodDays','supplier','purchaseProof','prescribingVet'];
export type PrefillField = (typeof PREFILL_FIELDS)[number];
export function applyPrefill(values: TreatmentFormValues, prefill: TreatmentPrefill): { values: TreatmentFormValues; filled: PrefillField[] }; // nur leere Felder; 0 → "0"
export function useTreatmentPrefill(product: string): TreatmentPrefill | null; // 500 ms entprellt, ≥ 2 Zeichen, nur bei useConnectivity() === 'online'
```
- Je vorbelegtem Feld steht der Hinweis „aus letzter Behandlung vom {TT.MM.JJJJ}“. Er verschwindet, sobald das Feld bearbeitet wird. Der Knopf „Vorbelegung zurücknehmen“ leert nur unberührte, vorbelegte Felder. Dasselbe gilt im Sammelmodus (Task 13).
- [ ] **Step 1: Failing tests**
- `prefill.spec.ts`:
- `it('fills empty fields only')`.
- `it('fills withdrawalPeriodDays 0 as "0"')`.
- `it('does not fill null values')`.
- `treatments/new/page.spec.tsx`:
- `it('after „Oxuvar" fills Wirkstoff, Wartezeit, Lieferant, Beleg and Tierarzt with the hint')`.
- `it('never overwrites a typed Wirkstoff')`.
- `it('prefilled values stay editable and a cleared one is sent as null')`.
- `it('sends no prefill request while offline')`.
- [ ] **Step 2:** `npx vitest run src/lib/treatments "src/app/(app)/treatments"` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 13: Frontend – Sammelerfassung „Für mehrere Völker“
**Status:** parallel ab sofort. Die Tests nutzen Mocks gegen den Vertrag aus Task 4, die End-to-End-Abnahme folgt nach Task 4. T3-intern nach den Tasks 6, 11 und 12.
**Files:**
- Create: `src/lib/bulk/api.ts` und `.spec.ts`, `src/components/form/colony-multi-select.tsx` und `.spec.tsx`, `src/components/form/capture-target-toggle.tsx`
- Modify: `src/lib/offline/db.ts` und `sync.ts` (`queueBulk`, `retryBulkWithout`, Bulk-Drain), `src/components/offline/pending-queue-sheet.tsx` und Spec
- Modify: `treatments/new/{page.tsx,treatment-form-values.ts}`, `feedings/new/{page.tsx,feeding-form-values.ts}`, `varroa/new/{page.tsx,varroa-form-values.ts}` mit Specs
**Interfaces:**
- Consumes: `BulkKind`, `PendingBulkEntry`, `QueueError` (Task 6). `ApiError.body` (Task 5). `mitesPerDay` und `varroaLight` (Task 11). `applyPrefill` (Task 12).
- Produces:
```ts
export const BULK_MAX_COLONIES = 100;
export interface BulkCreateResult<T> { bulkId: string; records: T[] }
export interface BulkCreateTreatmentInput extends Omit<CreateTreatmentInput, 'colonyId'> { colonyIds: string[] }
export interface BulkCreateFeedingInput extends Omit<CreateFeedingInput, 'colonyId'> { colonyIds: string[] }
export interface BulkVarroaEntry { colonyId: string; miteCount?: number | null; calculatedValue?: number | null; assessment?: string | null }
export interface BulkCreateVarroaInput extends Omit<CreateVarroaMeasurementInput, 'colonyId' | 'miteCount' | 'calculatedValue' | 'assessment'> { entries: BulkVarroaEntry[] }
export function createTreatmentsBulk(input: BulkCreateTreatmentInput, options?: CreateOptions): Promise<BulkCreateResult<Treatment>>;
export function createFeedingsBulk(input: BulkCreateFeedingInput, options?: CreateOptions): Promise<BulkCreateResult<Feeding>>;
export function createVarroaBulk(input: BulkCreateVarroaInput, options?: CreateOptions): Promise<BulkCreateResult<VarroaMeasurement>>;
export function invalidColonyIdsOf(error: unknown): string[];
export function queueBulk(kind: BulkKind, input: unknown, idempotencyKey: string, ownerUserId: string): Promise<void>;
export function retryBulkWithout(entryId: number, colonyIds: readonly string[]): Promise<void>; // gleicher Key, lastError leeren
export interface ColonyMultiSelectProps { id: string; apiaryId: string | null; onApiaryChange: (apiaryId: string | null) => void;
selectedIds: readonly string[]; onChange: (ids: string[]) => void; error?: string | null }
// Form-Werte += target: 'single' | 'bulk'; bulkApiaryId: string | null; bulkColonyIds: string[]; (Varroa) bulkMiteCounts: Record<string, string>
```
- Bedienung:
- Umschalter „Für: Ein Volk / Mehrere Völker“.
- Die Standwahl wählt alle lebenden Völker des Stands vor, beendete erscheinen nie. Einzelne Völker lassen sich abwählen.
- Zeilen `min-h-14` mit Checkbox und `colonyOptionLabel`. Dazu „Alle wählen“ und „Alle abwählen“ sowie live „{k} von {n} Völkern gewählt“.
- Mehr als 100 Völker: Fehler „Höchstens 100 Völker auf einmal.“
- Hinweis bei Chargennummer bzw. gemeinsamen Feldern: „Gilt für alle gewählten Völker.“
- Varroa im Sammelmodus: eine Zahl je Volk mit Rate und Ampel je Zeile.
- Nach dem Speichern der Toast „{Art} für {n} Völker gespeichert“, dann zum Stand bzw. zu `/dashboard`.
- Bei 400 werden die ungültigen Völker per Code genannt, das Formular bleibt stehen.
- Im Sheet: „Ohne {Codes} erneut senden“.
- [ ] **Step 1: Failing tests**
- `colony-multi-select.spec.tsx`:
- `it('choosing an apiary preselects all its living colonies')`.
- `it('deselecting one keeps the rest')`.
- `it('rows are 56 px and checkboxes are named by colony code')`.
- `bulk/api.spec.ts`: `it('sends the Idempotency-Key')`. `it('invalidColonyIdsOf reads ApiError.body')`.
- `treatments/new/page.spec.tsx`:
- `it('bulk mode sends one POST /treatments/bulk with colonyIds and the shared fields')`.
- `it('offline queues exactly one bulk entry')`.
- `it('a 400 names the ended colony by code and keeps the form')`.
- `feedings/new/page.spec.tsx`: Sammelmodus sendet `colonyIds`.
- `varroa/new/page.spec.tsx`: `it('bulk sends entries with each colony\'s own count')`.
- `pending-queue-sheet.spec.tsx`: `it('„Ohne V-3 erneut senden" resends the remaining ids with the same key')`. Gehört zu Review Focus 4.
- [ ] **Step 2:** `npx vitest run src/lib/bulk src/components/form "src/app/(app)/treatments" "src/app/(app)/feedings" "src/app/(app)/varroa" src/components/offline` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Nach Task 4 manuell gegen das lokale Backend prüfen: 3 Völker ergeben 3 Datensätze. Kein Commit.
---
**Carry-forward (Orchestrator, aus Task 12):** Die Sammelerfassung für Behandlungen nutzt `applyPrefill`, `PREFILL_FIELDS` und `useTreatmentPrefill` aus `src/lib/treatments/`. Hinweis „aus letzter Behandlung“ und „Vorbelegung zurücknehmen“ sind heute lokal in `treatments/new/page.tsx` – vor der Wiederverwendung in eine gemeinsame Komponente ziehen (keine Kopie).
**Carry-forward (Orchestrator, aus Review von Task 6):**
- Der Bulk-Drain läuft innerhalb von `runSync` (Gate + Generation, `cancelSync()`); Wiederholungen ebenso.
### Task 14: Frontend – Stand-Seite mit Völkern und Ampel, Standrunde
**Status:** parallel ab sofort. T3-intern nach den Tasks 3, 7 und 9.
**Files:**
- Create: `src/lib/apiaries/field-api.ts`, `src/lib/colonies/status-light.ts` und `.spec.ts`, `src/components/colonies/status-light-badge.tsx`, `src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx` und `.spec.tsx`, `src/lib/rounds/round-state.ts` und `.spec.ts`, `src/lib/rounds/use-stand-round.ts`, `src/components/rounds/round-next-panel.tsx`
- Modify: `src/app/(app)/apiaries/[id]/page.tsx`, `inspections/new/page.tsx` und Spec
**Interfaces:**
- Consumes: `ColonyOverview` und `ColonyOverviewRow` (`field-types.ts`, Task 6). `captureBackTarget` (Task 7).
- Produces:
```ts
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview>; // Key ["apiaries", apiaryId, "colony-overview"]
export type StatusLight = 'green' | 'yellow' | 'red';
export const INSPECTION_STALE_DAYS = 14;
export interface StatusLightResult { light: StatusLight; reasons: string[] }
export function colonyStatusLight(row: ColonyOverviewRow, options: { today: string; inspectionsVisible: boolean }): StatusLightResult;
// rot: Status QUEENLESS · eine Bedingung CRITICAL · openCritical > 0
// gelb: Status WEAK/CONSPICUOUS · eine Bedingung WEAK · > 14 Tage oder noch nie durchgesehen (nur wenn inspectionsVisible) · openWarnings > 0
// Gründe: „Weisellos", „Gesundheit kritisch", „Futter schwach", „{n} Tage ohne Durchsicht", „Noch keine Durchsicht", „{n} Warnung(en)", „{n} kritische Meldung(en)"
export interface StandRound { version: 1; apiaryId: string; apiaryName: string; colonies: { id: string; code: string }[]; doneColonyIds: string[]; startedAt: string }
export function roundStorageKey(userId: string): string; // `apicore:round:${userId}` (sessionStorage)
export function startRound(userId: string, apiary: { id: string; name: string }, colonies: { id: string; code: string }[]): StandRound; // ersetzt eine laufende Runde
export function readRound(userId: string): StandRound | null;
export function markColonyDone(userId: string, colonyId: string): StandRound | null; // ignoriert Völker außerhalb der Runde
export function nextRoundColony(round: StandRound, stillInApiary?: ReadonlySet<string>): { id: string; code: string } | null;
export function endRound(userId: string): void;
export function clearAllRounds(): void;
```
- Stand-Seite:
- Primäraktion ist „Runde starten“. „Ernte erfassen“ wandert in `moreActions`.
- Die Karte „Völker ({n})“ steht oben links. Zeilen `min-h-14` verlinken auf das Volk und zeigen Code, `StatusLightBadge` (Icon und Text „Gut“, „Achtung“ oder „Kritisch“ plus Gründe) und Status.
- „Runde starten“ führt zu `/inspections/new?colonyId=<erstes>&round=<apiaryId>` und ist bei 0 Völkern deaktiviert.
- Im Formular mit `round`, wenn die Runde aktiv ist und passt:
- Chip „Runde {Stand}: {k} von {n}“.
- Nach dem Speichern (online oder in der Warteschlange): `markColonyDone`, dann `RoundNextPanel` statt Navigation. Es zeigt „Gespeichert“, als Hauptknopf „Weiter mit {code}“ und als zweiten Knopf „Runde beenden“. Ist nichts mehr offen: „Runde abgeschlossen – alle {n} Völker“ mit „Zum Stand“.
- `stillInApiary` stammt aus der Übersichts-Query, offline aus dem Paket (Task 15).
- „Runde beenden“ zeigt den Toast „Runde beendet – {k} von {n} Völkern“ und führt zu `/apiaries/:id`.
- Ohne passende Runde gilt das normale Verhalten.
- [ ] **Step 1: Failing tests**
- `status-light.spec.ts` als Tabelle: QUEENLESS → rot. Gesundheit CRITICAL → rot. KRITISCH-Meldung → rot. WEAK → gelb. 15 Tage → gelb. 14 Tage → grün. Noch nie → gelb. `inspectionsVisible: false` ignoriert das Alter. Die schlechteste Ampel gewinnt. Gründe als Text.
- `round-state.spec.ts`:
- `it('next skips done colonies in code order')`.
- `it('next skips a colony moved away or ended mid-round')`, Review Focus 3: `stillInApiary` ohne V-2, also kommt V-3.
- `it('null when all done')`.
- `it('per user and survives a reload')`.
- `it('markColonyDone ignores colonies outside the round')`.
- `apiary-colonies-card.spec.tsx`:
- `it('lists living colonies with light and reasons')`.
- `it('„Runde starten" opens the first colony\'s inspection with the round param')`.
- `it('„Runde starten" is disabled without colonies')`.
- `inspections/new/page.spec.tsx`:
- `it('after save shows „Weiter mit B3" instead of going to the colony')`.
- `it('„Runde beenden" ends the round')`.
- `it('an offline save also advances the round')`.
- [ ] **Step 2:** `npx vitest run src/lib/colonies src/lib/rounds "src/app/(app)/apiaries" "src/app/(app)/inspections"` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
### Task 15: Frontend – Offline-Standmodus (Stand laden, Offline-Seiten, Service Worker, Deep Links)
**Status:** parallel ab sofort. T3-intern nach den Tasks 3, 6 und 14.
**Files:**
- Create: `src/lib/apiaries/offline-pack.ts` und `.spec.ts`, `src/lib/offline/pack-fallback.ts` und `.spec.ts`, `src/components/offline/load-pack-button.tsx`
- Create: `src/app/(app)/offline/page.tsx`, `src/app/(app)/offline/apiary/page.tsx`, `src/app/(app)/offline/colony/page.tsx` und `.spec.tsx` (statisch; `useSearchParams` in `<Suspense>`)
- Create: `public/sw-routes.js`, Test `src/lib/offline/sw-routes.spec.ts`. Modify: `public/sw.js`
- Modify: `src/components/colony-select-field.tsx` und Spec, `src/lib/inspections/use-last-inspection.ts`, `src/lib/apiaries/field-api.ts` (Übersicht mit Paket-Fallback), `src/components/form/colony-multi-select.tsx`, `apiaries/[id]/apiary-colonies-card.tsx`, `src/lib/offline/sync.ts` (Pakete nach dem Sync auffrischen)
**Interfaces:**
- Consumes: `StoredPack`, `OfflinePackV1` (Task 6). `isNetworkError` (Task 5). `colonyStatusLight` und `nextRoundColony` (Task 14).
- Produces:
```ts
export const PACK_STALE_MS = 24 * 60 * 60 * 1000;
export const PACK_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
export const PACK_AUTO_REFRESH_AFTER_MS = 60 * 60 * 1000;
export function fetchOfflinePack(apiaryId: string): Promise<OfflinePackV1>;
export function loadPack(apiaryId: string, ownerUserId: string): Promise<StoredPack>;
export function getPack(apiaryId: string): Promise<StoredPack | null>;
export function listPacks(ownerUserId: string): Promise<StoredPack[]>;
export function findPackForColony(colonyId: string, ownerUserId: string): Promise<StoredPack | null>;
export function deleteAllPacks(): Promise<void>;
export function purgeOldPacks(now: number): Promise<number>;
export function isPackStale(pack: StoredPack, now: number): boolean;
export function refreshPacksAfterSync(ownerUserId: string, now: number): Promise<void>; // Pakete älter als 1 h still neu laden
export function withPackFallback<T>(fetcher: () => Promise<T>, fromPacks: (packs: StoredPack[]) => T | null, ownerUserId: string): Promise<T>;
// nur bei isNetworkError; ApiError (403/404) wird nie durch Paketdaten ersetzt; Paket-Queries mit networkMode: 'always'
// public/sw-routes.js → self.ApiCoreSwRoutes (und module.exports für Vitest):
// offlineFallbackFor(url: URL): { kind: 'redirect', to: string } | { kind: 'cached-path', path: string } | { kind: 'redirect', to: '/offline' }
// isShellRoute(pathname: string): boolean; extractAssetUrls(html: string): string[]; shouldCacheApi(url: URL): boolean
```
- Service Worker:
- Caches `apicore-shell-v4` und `apicore-data-v1`.
- `SHELL_ROUTES = ['/', '/login', '/dashboard', '/offline', '/offline/apiary', '/offline/colony', '/inspections/new', '/varroa/new', '/treatments/new', '/feedings/new', '/swarm-control/new']`.
- Bei `install` und auf die Nachricht `WARM_SHELL`: HTML der Shell-Routen und ihre `/_next/static/…`-Assets cachen.
- Navigation: network-first mit 4 s. Bei Fehler: dieselbe URL aus dem Cache. Sonst `/colonies/:id` → 302 auf `/offline/colony?id=:id` und `/apiaries/:id` → `/offline/apiary?id=:id`. Formular-Routen mit Query aus dem Pfad-Cache (`ignoreSearch`). Alles andere → `/offline`.
- `/api/runtime-config`: network-first, Fallback auf den Cache.
- API-GETs wie bisher, aber nie `/api/v1/auth/`.
- Nachricht `WIPE_CACHES` löscht alle Caches.
- „Für offline laden“ steht in der Völker-Karte. Danach erscheint „Geladen vor {Zeit}“. Ab 24 h der Hinweis „Stand-Daten von {Datum} – bei Netz neu laden“.
- Offline-Seiten:
- Sie sind nur lesend und zeigen dieselben Bausteine wie online (`StatusLightBadge`), dazu Knöpfe zu den Erfassungsformularen.
- Für ein nicht geladenes Volk: „Dieses Volk ist nicht offline geladen. Lade den Stand bei Netz mit „Für offline laden“.“
- Das gecachte HTML der (app)-Routen enthält keine Nutzerdaten, weil das Layout ohne Auth `null` rendert. Das im Build prüfen.
- [ ] **Step 1: Failing tests**
- `offline-pack.spec.ts` (fake-indexeddb):
- `it('stores the pack with owner and time')`.
- `it('findPackForColony')`.
- `it('purges packs older than 14 days')`.
- `it('stale after 24 h')`.
- `pack-fallback.spec.ts`:
- `it('falls back to the pack on a network error')`.
- `it('rethrows an ApiError instead of showing pack data')`.
- `it('rethrows when the pack has nothing')`.
- `colony-select-field.spec.tsx`: `it('offers the pack\'s living colonies while offline')`.
- `sw-routes.spec.ts` (Datei per `createRequire` laden):
- `it('maps /colonies/abc to /offline/colony?id=abc and encodes ids')`.
- `it('serves /inspections/new?colonyId=x from the cached shell path')`.
- `it('unknown routes go to /offline')`.
- `it('never caches /api/v1/auth/*')`.
- `it('extracts _next/static asset urls')`.
- `offline/colony/page.spec.tsx`:
- `it('renders colony, queen, last three inspections and open tasks from the pack')`.
- `it('shows the not-loaded message for an unknown colony')`.
- `it('shows the pack age and the stale hint')`.
- `sync.spec.ts`, Review Focus 1: `it('an inspection for a colony ended since the pack was loaded stays queued with the server message and the packs are refreshed')`. Das Anlegen wirft `ApiError(400)`, `lastError` wird gesetzt, `refreshPacksAfterSync` wird aufgerufen.
- [ ] **Step 2:** `npx vitest run src/lib/apiaries src/lib/offline src/components "src/app/(app)/offline"` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün.
- [ ] **Step 5: Manuelle Prüfung** mit `npm run build && npm run start` (nicht im Dev-Modus) und DevTools Offline bzw. Flugmodus am Handy:
- Kaltstart auf `/`.
- QR-Link `/colonies/<id>` landet auf der Offline-Ansicht.
- Durchsicht offline speichern, Band zeigt „Offline – 1 Eintrag wartet“.
- Wieder online: Sync läuft, Band verschwindet.
- Kein Commit.
---
### Task 16: Frontend – Offline-Sitzung, Kaltstart und Abmelden
**Status:** parallel ab sofort. T3-intern nach den Tasks 5, 6, 14 und 15.
**Files:**
- Create: `src/lib/auth/offline-identity.ts` und `.spec.ts`, `src/components/auth/logout-confirm-dialog.tsx` und `.spec.tsx`
- Modify: `src/lib/auth/auth-context.tsx` (Test: neu `auth-context.spec.tsx`), `src/app/(app)/layout.tsx` und Spec, `src/app/(auth)/login/page.tsx` und Spec, `src/app/(app)/colonies/[id]/edit-dialog-parts.tsx` (`EditTriggerButton` offline deaktiviert)
**Interfaces:**
- Consumes: `useConnectivity` (Task 5). `clearQueueOf` und `summarizeQueue` (Task 6). `deleteAllPacks` (Task 15). `clearAllRounds` (Task 14). Löschen der Entwurfsfotos (Task 10).
- Produces:
```ts
export const OFFLINE_IDENTITY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // = SESSION_MAX_AGE
export function saveIdentity(user: AuthUser, now?: number): Promise<void>; // IDB 'meta' Key 'identity' → { user, cachedAt }
export function loadIdentity(now?: number): Promise<AuthUser | null>; // abgelaufen → löschen, null
export function clearIdentity(): Promise<void>;
export function markLogoutPending(): void; export function isLogoutPending(): boolean; export function clearLogoutPending(): void; // localStorage 'apicore:logout-pending'
// AuthContextValue += sessionMode: 'online' | 'offline'; logout(options?: { discardQueue?: boolean }): Promise<void>
```
- Start:
1. Mit `isLogoutPending()`: zuerst `logoutRequest()`, bei Erfolg `clearLogoutPending()`. Danach abgemeldet, ohne Refresh.
2. Sonst Refresh. Erfolg → online und `saveIdentity`. `null` → abgemeldet und `clearIdentity`, die Warteschlange bleibt. Netzfehler → `loadIdentity()` → offline-Modus oder abgemeldet.
- Wieder online im offline-Modus: Refresh. Bei `null` folgt `setUser(null)` mit dem Toast „Sitzung abgelaufen – bitte neu anmelden. {n} Einträge warten und werden danach gesendet.“
- Login als anderer Nutzer: Pakete, Runden, Caches und Entwurfsfotos des Vorgängers löschen. Seine Warteschlange bleibt und wird nicht gesendet.
- Abmelden:
- `logoutRequest` wird versucht, bei Netzfehler `markLogoutPending()`.
- Danach: Token auf `null`, `clearAllFormDrafts`, `clearIdentity`, `deleteAllPacks`, `clearAllRounds`, Entwurfsfotos löschen, mit `discardQueue` auch `clearQueueOf(userId)`, `caches` leeren und `WIPE_CACHES` an den Service Worker.
- Dialog vor dem Abmelden bei eigenen wartenden Einträgen: „{n} Einträge sind noch nicht gesendet.“ mit „Erst senden“ (nur online), „Abmelden und Einträge löschen“ (destruktiv) und „Abbrechen“.
- Login offline: „Anmelden braucht eine Internetverbindung.“
- Bearbeiten-Knöpfe sind offline deaktiviert, mit dem Text „Bearbeiten braucht Netz“.
- [ ] **Step 1: Failing tests**
- `offline-identity.spec.ts`: `it('stores exactly the user fields, never a token')`, Schlüsselvergleich gegen `AuthUser`. `it('expires after 7 days')`.
- `auth-context.spec.tsx`:
- `it('cold start offline with a cached identity shows the app in offline mode')`.
- `it('cold start offline without or with an expired identity goes to login')`, Review Focus 5.
- `it('back online with a 401 refresh sends the user to login and keeps the queue')`.
- `it('a pending logout revokes the session before any refresh')`.
- `it('logging in as another user wipes the previous user\'s packs and caches but keeps their queue unsent')`.
- `it('never writes the access token to localStorage or IndexedDB')`: Spy auf `Storage.prototype.setItem` und IDB `put`, kein Wert enthält den Token-String.
- `logout-confirm-dialog.spec.tsx`:
- `it('asks before deleting unsent entries')`.
- `it('logs out directly when nothing waits')`.
- `it('offline logout marks the logout as pending')`.
- [ ] **Step 2:** `npx vitest run src/lib/auth src/components/auth "src/app/(app)/layout.spec.tsx" "src/app/(auth)"` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** Tests → PASS. Lint, Typecheck, Test und Build grün. Kein Commit.
---
**Carry-forward (Orchestrator, aus Reviews der Tasks 6 und 14) – Pflicht:**
- `await cancelSync()` vor JEDEM Token-Wechsel oder Nutzerverlust: beim Abmelden vor `logoutRequest` (nicht erst vor dem Löschen des Tokens), in `applyAccessToken`/`login`/`verifyMfa`/`completeSso` vor `storeAccessToken`, und im Pfad „Sitzung abgelaufen“ (Refresh liefert null → `setUser(null)`).
- Bricht das Abmelden ab oder wirft es, `resumeSync()` im `finally` – sonst bleibt das Gate für den angemeldeten Nutzer zu.
- Zwischen `cancelSync()` und `setUser(null)` darf das `(app)`-Layout nicht neu mounten (kein Umschalten von `isLoading`/`sessionMode`), sonst öffnet das Band das Gate für den alten Nutzer wieder.
- `/login` leitet einen angemeldeten Nutzer um (oder ruft vor der neuen Anmeldung `cancelSync()`).
- Zweiter Tab: Nach einem Refresh prüfen, dass die Nutzer-ID gleich geblieben ist; sonst hart abmelden (optional zusätzlich BroadcastChannel-Logout-Signal).
- Offline-Sitzung (gecachte Identität, kein Token): Gate bleibt zu, solange kein Token da ist – `resumeSync()` erst nach `storeAccessToken` bzw. nur bei `sessionMode === 'online'`; Auto-Sync des Bands im Offline-Modus unterdrücken; ein 401 durch abgelaufene Sitzung darf Einträge nicht als „abgelehnt“ markieren.
- Nutzerwechsel/Abmelden räumt auch Entwurfsfotos auf (Foto-Store, Sitzungs-Fotos ohne Queue-Eintrag) – aus Task 10.
- Abmelden löscht `apicore:round:*` (`clearAllRounds`) und zeigt eigentümerlose Alt-Einträge (vor v3) im Abmelde-Dialog; Texte über `src/lib/offline/queue-texts.ts`.
### Task 17: Dokumentation
**Status:** parallel ab sofort, inhaltlich am Ende.
**Files:** Modify `ARCHITECTURE.md` (Abschnitt 2 und neue Annahmen 22–27), `TASKS.md` (Abschnitt T3 mit Haken)
- [ ] **Step 1:** Neue Annahmen in ARCHITECTURE.md:
- **22 Offline-Standmodus:** Paketinhalt ohne Koordinaten und Adresse, 24 h veraltet, 14 Tage Löschung, Navigations-Fallback auf `/offline/*?id=`, keine Auth-Antworten im Cache.
- **23 Offline-Sitzung:** zwischengespeicherte Identität mit 7 Tagen Gültigkeit, nie Tokens, ausstehender Logout-Widerruf, Warteschlange je Besitzer.
- **24 Fotos:** Neu-Kodierung per Canvas, EXIF/XMP-Prüfung vor dem Upload, 10 MB, idempotenter Upload, Foto wartet auf die Server-ID der Durchsicht.
- **25 Sammelerfassung:** alles oder nichts, `FOR SHARE`, `bulkId` im Event, Entscheidung zu K1 und K2.
- **26 Skalen:** Anzeige „5 = gut“, Schwarmstimmung nur in der Anzeige umgedreht.
- **27 Ampeln:** Varroa gelb ab der halben Schwelle der aktiven Regel. Stand-Ampel nach den Regeln aus Task 14 mit `INSPECTION_STALE_DAYS = 14`. Requests mit 4-s-Timeout.
- [ ] **Step 2:** ARCHITECTURE.md Abschnitt 2: Offline-Seiten, `nav-config.ts` als einzige Quelle, „+ Neu“ als Sheet.
- [ ] **Step 3:** TASKS.md: T3-Abschnitt mit den Tasks 1–16. Kein Commit.
---
## Abdeckung der Spec (T3-Punkt → Task)
| T3-Punkt (Spec §10 plus Eigentümer-Entscheidung) | Tasks |
|---|---|
| Standrunde: Stand-Seite zeigt Völker mit Ampel, „Runde starten“ | 3, 14 |
| Standrunde: „Weiter mit B3“ nach dem Speichern, Rundenzustand offline | 14, 15 |
| Durchsicht: Werte der letzten Durchsicht sichtbar („Letztes Mal … übernehmen“, „Alles wie letztes Mal“) | 8, 9, 15 (offline) |
| Durchsicht: Wetter automatisch aus den Stand-Daten | 2, 9 |
| Durchsicht: Waben per Plus/Minus (`colonyStrength`, neu `broodFrames`, Altfeld `broodAmount` nur im Bearbeiten-Dialog), Backup | 1, 8, 9 |
| Durchsicht: Skalen in einer Richtung (5 = gut), Schwarmstimmung umgedreht angezeigt | 8, 9 |
| Durchsicht: Foto mit Kamera, offline, ohne EXIF/GPS, idempotent | 2, 6, 10 |
| Varroa: Zeitraum 1/2/3/7 Tage oder „anders“ | 11 |
| Varroa: Milben/Tag live mit Ampel aus der aktiven Regel | 2, 11 |
| Varroa: Trend in Milben/Tag mit Schwellenlinie | 11 |
| Sammelerfassung Behandlung/Fütterung/Varroa, eine Transaktion, `@Idempotent()` | 4, 13 |
| Gemeinsame `batchNumber` (K1) | 4, 13 |
| Vorbelegung aus der letzten Behandlung mit demselben Mittel | 2, 12, 13 |
| Offline: „Stand laden“ (Völker, Königinnen, letzte 3 Durchsichten, offene Aufgaben) | 3, 6, 15 |
| Offline: Kaltstart (App-Hülle, `runtime-config`, Sitzung) | 15, 16 |
| Offline: Timeout 4 s | 5 |
| Offline: Band „Offline – X Einträge warten“ | 6 |
| Offline: QR/NFC-Deep-Links offline | 15 |
| Offline: Abmelden löscht Pakete, Warteschlange (nach Rückfrage) und Caches | 16 |
| Navigation: „Mehr“-Sheet aus einer Nav-Quelle | 7 |
| Navigation: „+ Neu“ als Sheet mit Icons und 56-px-Zeilen, auch am Desktop | 7 |
| Navigation: Zurück-Link auf allen `new`-Seiten mit Rückfrage bei Entwurf | 7 |
| Flugmodus-Regression (`networkMode`), entdeckt beim Erkunden | 5 |
| EXIF-Leck im bestehenden Dokumenten-Upload, entdeckt beim Erkunden | 10 |
| Dokumentation der neuen Annahmen | 17 |
## Abstimmung mit dem Rückgrat-Plan (Orchestrator, 2026-10-13)
Beide Pläne laufen im selben Arbeitsbaum. Damit sich keine Dateien überschneiden, gilt:
| T3-Task | startet nach |
|---|---|
| 1 (`Inspection.broodFrames`, `schema.prisma`) | Rückgrat Task 2 (Schema und Migration des Rückgrats) |
| 2 (Lese-Endpunkte in varroa/treatments/documents) | Rückgrat Welle H (Tasks 11–14) |
| 3 (Stand-Übersicht, Offline-Paket) | sofort |
| 4 (Sammelerfassung) | Rückgrat Tasks 11 und 12 |
| 5–16 (Frontend) | sofort; Task 13 braucht T3 Task 4 für den e2e-Weg, Task 15 braucht T3 Task 3 |
| 17 (Doku) | zuletzt, nach Rückgrat-Doku |
Das Frontend von Rückgrat Task 25 („Verwaltung > System“) und T3 Task 7 (Navigation) berühren beide die Navigationskonfiguration; T3 Task 7 läuft zuerst, Rückgrat Task 25 trägt die neue Seite danach in dieselbe Konfiguration ein.