Files
ApiCore/docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

84 KiB
Raw Blame History

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'):

    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:

    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:

    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:

    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:

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


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:

    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:

    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:

    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:

    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:

    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:

    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:

    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.

Carry-forward (Orchestrator, Session 2, aus Reviews T3 Task 4 und 15): Der Idempotency-Key bindet (Nutzer, Key, Scope), nicht den Body – je unterschiedlicher Sammel-Absendung ein eigener Key, nach Erfolg nie mit anderer Völkerauswahl wiederverwenden. Eine 409 „wird bereits verarbeitet“ heißt „später erneut“, nicht Fehler. Ein 400 mit invalidColonyIds löst wie bei Einzeleinträgen das erzwungene Neuladen der betroffenen Stand-Pakete aus und bietet „Ohne diese Völker neu senden“ an. Die Völkerauswahl (colony-multi-select.tsx, neu) nutzt withPackFallback + livingColoniesFromPacks + networkMode: "always" wie LivingColonySelectField.

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:

    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.


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:

    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:

    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.

Carry-forward (Orchestrator, Session 2, aus Review Task 10): (a) Übergabe-Marker (src/lib/photos/handover.ts) bekommen createdAt und ein Höchstalter in purgeStaleDraftPhotos; beim Abmelden und beim Nutzerwechsel werden Marker anderer Nutzer entfernt. (b) Abmelden wartet auf cancelSync() und ruft clearQueueOf nach Rückfrage (siehe frühere Carry-forwards). (c) photoSessionId wird beim Absenden festgehalten, nicht erst in onSaved aus den aktuellen Werten gelesen (use-capture-mutation.ts:69), damit „Weitermachen“ während eines laufenden Speicherns keine Fotos der falschen Durchsicht zuordnet. Carry-forward (Orchestrator, Session 2, aus Review Task 15): Beim Abmelden/Nutzerwechsel: (a) Generationszähler im Service Worker, damit eine API-Antwort, deren Abruf vor WIPE_CACHES begann, danach nicht mehr in apicore-data-v1 geschrieben wird; (b) den TanStack-Query-Cache leeren (queryClient.clear()), weil Schlüssel wie colonyOverviewKey und ["colonies"] nicht je Nutzer sind; (c) API-Cache und Offline-Pakete des vorigen Nutzers löschen. Carry-forward (Orchestrator, Session 2, Review Task 15 Runde 2): wipeCaches setzt apiOriginLoaded = Promise.resolve(null) synchron am Anfang, damit eine Anfrage während des Löschens den Daten-Cache nicht mit der Antwort des vorigen Nutzers neu anlegt.

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.