# 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 -- `. - 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 | null): number | null` - Produces: `GET /varroa-measurements/threshold` → `{ thresholdMitesPerDay: number | null }`, Berechtigung `@RequirePermission('varroa:read', 'varroa:create')` - Produces: `GET /treatments/last-by-product?product=` → `{ 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` 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[]; queens: Pick[] | null; inspections: Omit[] | null; tasks: Pick[] | null; } ``` - `ApiaryFieldService.colonyOverview(apiaryId: string, userId: string): Promise`, `ApiaryFieldService.offlinePack(apiaryId: string, userId: string): Promise`. - [ ] **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(':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>; // 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 { 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>`. - 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; // 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(path: string, formData: FormData, options?: { headers?: HeadersInit; timeoutMs?: number }): Promise; 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 += 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; // genauso für die 5 anderen export interface QueueSummary { own: number; failed: number; foreign: number } export function summarizeQueue(currentUserId: string): Promise; export interface QueueItem { store: string; id: number | string; label: string; queuedAt: string; lastError?: QueueError; photoCount?: number } export function listQueue(currentUserId: string): Promise; export function discardQueueItem(item: QueueItem): Promise; export function clearQueueOf(ownerUserId: string): Promise; // sync.ts export interface SyncResult { synced: number; failed: number; skippedForeign: number } export function syncPendingEntries(currentUserId: string): Promise; // ersetzt syncPendingInspections // drain(...) erhält onCreated?: (entry, result) => Promise; ApiError → markFailed(store, id, QueueError) // use-capture-mutation.ts export type CaptureOutcome = { via: 'online'; result: R } | { via: 'queued'; idempotencyKey: string }; export function useCaptureMutation(options: { create: (input: I, options: CreateOptions) => Promise; queue: (input: I, idempotencyKey: string, ownerUserId: string) => Promise; failureMessage: string; onSaved: (outcome: CaptureOutcome, input: I) => void | Promise; }): { 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): 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 += 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): 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 ``. 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 ``), `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 } 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; // 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; export function addDraftPhoto(p: { sessionId: string; ownerUserId: string; blob: Blob; date: string }): Promise; // fileName `durchsicht-${date}-${n}.jpg` export function listSessionPhotos(sessionId: string): Promise; export function removePhoto(id: string): Promise; export function discardSessionPhotos(sessionId: string): Promise; export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise; // → ready export function attachSessionToQueuedInspection(sessionId: string, inspectionIdempotencyKey: string): Promise; // → waiting-for-inspection export function resolveQueuedInspection(inspectionIdempotencyKey: string, inspectionId: string): Promise; // → ready export function discardPhotosForInspection(inspectionIdempotencyKey: string): Promise; export function purgeStaleDraftPhotos(now: number): Promise; // Entwürfe älter als 3 Tage export function uploadReadyPhotos(currentUserId: string, upload?: typeof uploadDocument): Promise; ``` - Ablauf: - Erfassungsknopf: „Foto aufnehmen“ (size touch, Kamera-Icon) auf einem versteckten ``. - 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/`. - [ ] **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/ 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“. `` 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.