feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

+74 -4
View File
@@ -8,12 +8,21 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
import type { HiveOp } from "@/lib/hive/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** A change of a colony's hive made on the card, waiting for network. */
export interface HiveChangeQueueInput {
colonyId: string;
occurredOn: string;
source: "CARD";
ops: HiveOp[];
}
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
"pending-inspections": CreateInspectionInput;
@@ -23,6 +32,7 @@ export interface RecordQueueInputs {
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
"pending-phenology-reports": CreatePhenologyReportInput;
"pending-hive-changes": HiveChangeQueueInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
@@ -110,11 +120,11 @@ export interface OfflineDB extends DBSchema, RecordStores {
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung). Entries already
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already
// queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated.
const DB_VERSION = 4;
const DB_VERSION = 5;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
@@ -124,6 +134,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
"pending-swarm-control-checks",
"pending-harvests",
"pending-phenology-reports",
"pending-hive-changes",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
@@ -135,6 +146,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
"pending-phenology-reports": "Pflanzenmeldung",
"pending-hive-changes": "Beute-Änderung",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
@@ -183,6 +195,9 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
autoIncrement: true,
});
}
if (oldVersion < 5) {
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
@@ -282,6 +297,14 @@ export function queuePhenologyReport(
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
}
export function queueHiveChange(
input: HiveChangeQueueInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-hive-changes", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
@@ -445,6 +468,37 @@ export async function summarizeQueue(currentUserId: string): Promise<QueueSummar
);
}
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
function isHiveRefusal(error: QueueError | undefined): boolean {
if (!error) return false;
const code = (error.details as { code?: unknown } | null | undefined)?.code;
return error.status === 403 || code === "HIVE_CONFLICT";
}
/** A queued inspection that also carries a change of the hive. */
export function hasHiveChange(input: unknown): boolean {
return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined;
}
/**
* Ohne Beute-Änderung senden: takes the hive change out of a queued
* inspection and clears its rejection, so the next sync run sends the rest.
* The key stays: the server stored nothing for the rejected try.
*/
export async function retryInspectionWithoutHiveChange(entryId: number): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-inspections", "readwrite");
const entry = await tx.store.get(entryId);
if (entry && hasHiveChange(entry.input)) {
const input = { ...entry.input };
delete input.hiveChange;
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
await tx.done;
}
export interface QueueItem {
store: string;
id: number | string;
@@ -452,6 +506,10 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
canResendWithoutHiveChange?: boolean;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
@@ -501,19 +559,31 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store === "pending-hive-changes") {
return { ...item, hasHiveChange: true };
}
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections" || !entry.idempotencyKey) {
if (store !== "pending-inspections") {
return item;
}
const hive = hasHiveChange((entry as PendingEntry<unknown>).input)
? {
hasHiveChange: true,
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
}
: {};
if (!entry.idempotencyKey) {
return { ...item, ...hive };
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, photoCount } : item;
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));