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