Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

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 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vert
| Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. |
| Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. |
| Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). |
| Fehlversuche | Nach 20 ungültigen Schlüsseln (unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten bekommt diese IP-Adresse (bei IPv6 das ganze /64-Netz) für weitere ungültige Schlüssel `429`. Ein gültiger Schlüssel wird davon nie abgewiesen, auch nicht von einer gesperrten Adresse – sonst könnte jeder, der dieselbe Adresse nutzt oder vortäuscht, ERPNext oder den Shop aussperren. Nicht wie ein Schlüssel aufgebaute Tokens (`ack_…` im falschen Format) bekommen sofort `401` und zählen nicht. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. |
| Fehlversuche | Nach 20 ungültigen Schlüsseln (unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten bekommt diese IP-Adresse (bei IPv6 das ganze /64-Netz) für weitere ungültige Schlüssel 15 Minuten lang `429`; die Zählung gilt für alle ApiCore-Instanzen gemeinsam. Ein gültiger Schlüssel wird davon nie abgewiesen, auch nicht von einer gesperrten Adresse – sonst könnte jeder, der dieselbe Adresse nutzt oder vortäuscht, ERPNext oder den Shop aussperren. Nicht wie ein Schlüssel aufgebaute Tokens (`ack_…` im falschen Format) bekommen sofort `401` und zählen nicht. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. |
| Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. |
| Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. |
@@ -25,7 +25,7 @@ Benutzer mit dem Recht `erp-inventory:read` (z. B. die Rolle Administrator) kön
### Schlüssel anlegen (Inhaber)
1. ApiCore → Verwaltung → Automatisierung → API-Schlüssel.
2. Name: `ERPNext`. Unter „Was darf der Schlüssel?“ **nur** „Inventar-Spiegel lesen (ERPNext)“ ankreuzen (den vorausgewählten Shop-Haken entfernen).
2. Name: `ERPNext`. Unter „Berechtigung“ **„ERP-Inventar lesen“** wählen (vorausgewählt ist „Shop lesen“). Ein Schlüssel trägt genau ein Recht; einen Schlüssel mit Lese- und Buchungsrecht lehnt ApiCore ab.
3. IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe [Rollout](#11-rollout)). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen.
4. Schlüssel kopieren und **nur** als Kubernetes-Secret für den ERPNext-Abgleich hinterlegen (nicht in `config.json`, nicht ins Repo, nicht in Tickets).
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.