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:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user