feat: extend offline queueing to all quick-capture forms

Only Durchsicht had an offline fallback (queue to IndexedDB, sync on
reconnect); Behandlung, Fütterung, Varroamessung, Schwarmkontrolle and
Ernte just failed outright with no signal - exactly the situation a
beekeeper is in at the actual hive. Generalizes lib/offline/db.ts and
sync.ts to six queues (version-bumped IndexedDB schema, existing queued
inspections preserved), wires the same "server rejected it vs. no
network" fallback into all five remaining */new pages, and updates the
sync badge to count/drain all six.

Also: doc-hygiene pass (ARCHITECTURE.md/TASKS.md) to replace the stale
pg_dump/CronJob backup plan with the in-app BackupModule that actually
shipped earlier this session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-05 17:45:32 +02:00
1 parent ac45514356
commit 1f67cd5548
11 files changed
+297 -46

No files matched your search

+155 -20
View File
@@ -1,52 +1,187 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import { CreateInspectionInput } from "@/lib/inspections/types";
import { CreateTreatmentInput } from "@/lib/treatments/types";
import { CreateFeedingInput } from "@/lib/feedings/types";
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
interface OfflineDB extends DBSchema {
"pending-inspections": {
key: number;
value: PendingInspection;
};
}
type PendingStoreName =
| "pending-inspections"
| "pending-treatments"
| "pending-feedings"
| "pending-varroa-measurements"
| "pending-swarm-control-checks"
| "pending-harvests";
export interface PendingInspection {
interface PendingEntry<T> {
id: number;
input: CreateInspectionInput;
input: T;
queuedAt: string;
}
interface OfflineDB extends DBSchema {
"pending-inspections": { key: number; value: PendingEntry<CreateInspectionInput> };
"pending-treatments": { key: number; value: PendingEntry<CreateTreatmentInput> };
"pending-feedings": { key: number; value: PendingEntry<CreateFeedingInput> };
"pending-varroa-measurements": {
key: number;
value: PendingEntry<CreateVarroaMeasurementInput>;
};
"pending-swarm-control-checks": {
key: number;
value: PendingEntry<CreateSwarmControlCheckInput>;
};
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
}
const DB_NAME = "apicore-offline";
const DB_VERSION = 1;
const STORE_NAME = "pending-inspections";
// v1 only had "pending-inspections". v2 adds the other five quick-capture
// types - existing users' already-queued inspections must survive the
// 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 = 2;
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
if (!dbPromise) {
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 2) {
db.createObjectStore("pending-treatments", { keyPath: "id", autoIncrement: true });
db.createObjectStore("pending-feedings", { keyPath: "id", autoIncrement: true });
db.createObjectStore("pending-varroa-measurements", {
keyPath: "id",
autoIncrement: true,
});
db.createObjectStore("pending-swarm-control-checks", {
keyPath: "id",
autoIncrement: true,
});
db.createObjectStore("pending-harvests", { keyPath: "id", autoIncrement: true });
}
},
});
}
return dbPromise;
}
export async function queueInspection(input: CreateInspectionInput): Promise<void> {
async function queue<T>(store: PendingStoreName, input: T): Promise<void> {
const db = await getDb();
await db.add(STORE_NAME, { input, queuedAt: new Date().toISOString() } as PendingInspection);
await db.add(store, { input, queuedAt: new Date().toISOString() } as never);
}
export async function getPendingInspections(): Promise<PendingInspection[]> {
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
const db = await getDb();
return db.getAll(STORE_NAME);
return db.getAll(store) as Promise<PendingEntry<T>[]>;
}
export async function removePendingInspection(id: number): Promise<void> {
async function removePending(store: PendingStoreName, id: number): Promise<void> {
const db = await getDb();
await db.delete(STORE_NAME, id);
await db.delete(store, id);
}
export async function countPendingInspections(): Promise<number> {
async function countPending(store: PendingStoreName): Promise<number> {
const db = await getDb();
return db.count(STORE_NAME);
return db.count(store);
}
export type PendingInspection = PendingEntry<CreateInspectionInput>;
export function queueInspection(input: CreateInspectionInput): Promise<void> {
return queue("pending-inspections", input);
}
export function getPendingInspections(): Promise<PendingInspection[]> {
return getPending("pending-inspections");
}
export function removePendingInspection(id: number): Promise<void> {
return removePending("pending-inspections", id);
}
export function countPendingInspections(): Promise<number> {
return countPending("pending-inspections");
}
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
export function queueTreatment(input: CreateTreatmentInput): Promise<void> {
return queue("pending-treatments", input);
}
export function getPendingTreatments(): Promise<PendingTreatment[]> {
return getPending("pending-treatments");
}
export function removePendingTreatment(id: number): Promise<void> {
return removePending("pending-treatments", id);
}
export function countPendingTreatments(): Promise<number> {
return countPending("pending-treatments");
}
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
export function queueFeeding(input: CreateFeedingInput): Promise<void> {
return queue("pending-feedings", input);
}
export function getPendingFeedings(): Promise<PendingFeeding[]> {
return getPending("pending-feedings");
}
export function removePendingFeeding(id: number): Promise<void> {
return removePending("pending-feedings", id);
}
export function countPendingFeedings(): Promise<number> {
return countPending("pending-feedings");
}
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput): Promise<void> {
return queue("pending-varroa-measurements", input);
}
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
return getPending("pending-varroa-measurements");
}
export function removePendingVarroaMeasurement(id: number): Promise<void> {
return removePending("pending-varroa-measurements", id);
}
export function countPendingVarroaMeasurements(): Promise<number> {
return countPending("pending-varroa-measurements");
}
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput): Promise<void> {
return queue("pending-swarm-control-checks", input);
}
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
return getPending("pending-swarm-control-checks");
}
export function removePendingSwarmControlCheck(id: number): Promise<void> {
return removePending("pending-swarm-control-checks", id);
}
export function countPendingSwarmControlChecks(): Promise<number> {
return countPending("pending-swarm-control-checks");
}
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
export function queueHarvest(input: CreateHarvestInput): Promise<void> {
return queue("pending-harvests", input);
}
export function getPendingHarvests(): Promise<PendingHarvest[]> {
return getPending("pending-harvests");
}
export function removePendingHarvest(id: number): Promise<void> {
return removePending("pending-harvests", id);
}
export function countPendingHarvests(): Promise<number> {
return countPending("pending-harvests");
}
export async function countAllPending(): Promise<number> {
const counts = await Promise.all([
countPendingInspections(),
countPendingTreatments(),
countPendingFeedings(),
countPendingVarroaMeasurements(),
countPendingSwarmControlChecks(),
countPendingHarvests(),
]);
return counts.reduce((sum, count) => sum + count, 0);
}
@@ -11,6 +11,19 @@ vi.mock("@/lib/inspections/api", () => ({
vi.mock("./db", () => ({
getPendingInspections: vi.fn(),
removePendingInspection: vi.fn(),
// The other five queues are exercised by their own dedicated tests
// elsewhere; here they just need to resolve empty so runSync()'s
// Promise.all doesn't touch their (unmocked, real) create functions.
getPendingTreatments: vi.fn().mockResolvedValue([]),
removePendingTreatment: vi.fn(),
getPendingFeedings: vi.fn().mockResolvedValue([]),
removePendingFeeding: vi.fn(),
getPendingVarroaMeasurements: vi.fn().mockResolvedValue([]),
removePendingVarroaMeasurement: vi.fn(),
getPendingSwarmControlChecks: vi.fn().mockResolvedValue([]),
removePendingSwarmControlCheck: vi.fn(),
getPendingHarvests: vi.fn().mockResolvedValue([]),
removePendingHarvest: vi.fn(),
}));
const mockedCreateInspection = vi.mocked(createInspection);
+63 -5
View File
@@ -1,6 +1,24 @@
import { ApiError } from "@/lib/auth/api-client";
import { createInspection } from "@/lib/inspections/api";
import { getPendingInspections, removePendingInspection } from "./db";
import { createTreatment } from "@/lib/treatments/api";
import { createFeeding } from "@/lib/feedings/api";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { createHarvest } from "@/lib/harvests/api";
import {
getPendingInspections,
removePendingInspection,
getPendingTreatments,
removePendingTreatment,
getPendingFeedings,
removePendingFeeding,
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
getPendingHarvests,
removePendingHarvest,
} from "./db";
export interface SyncResult {
synced: number;
@@ -9,6 +27,9 @@ export interface SyncResult {
let inFlightSync: Promise<SyncResult> | null = null;
// Drains all six offline queues in one pass (not just inspections) - a
// beekeeper working a row of hives with no signal may have queued several
// different record types before connectivity returns.
export function syncPendingInspections(): Promise<SyncResult> {
if (!inFlightSync) {
inFlightSync = runSync().finally(() => {
@@ -18,15 +39,25 @@ export function syncPendingInspections(): Promise<SyncResult> {
return inFlightSync;
}
async function runSync(): Promise<SyncResult> {
const pending = await getPendingInspections();
// Generic drain: tries each queued entry in order, removes it on success,
// stops at the first non-ApiError (real network failure, still offline) so
// the rest of the queue is left untouched for the next sync attempt. An
// ApiError (the server actively rejected it, e.g. validation) is counted as
// failed and left in the queue too, rather than silently dropping data the
// user entered - the sync badge stays visible so it's not forgotten.
async function drain<T>(
getPending: () => Promise<{ id: number; input: T }[]>,
removePending: (id: number) => Promise<void>,
create: (input: T) => Promise<unknown>,
): Promise<SyncResult> {
const pending = await getPending();
let synced = 0;
let failed = 0;
for (const entry of pending) {
try {
await createInspection(entry.input);
await removePendingInspection(entry.id);
await create(entry.input);
await removePending(entry.id);
synced += 1;
} catch (error) {
failed += 1;
@@ -38,3 +69,30 @@ async function runSync(): Promise<SyncResult> {
return { synced, failed };
}
async function runSync(): Promise<SyncResult> {
const results = await Promise.all([
drain(getPendingInspections, removePendingInspection, createInspection),
drain(getPendingTreatments, removePendingTreatment, createTreatment),
drain(getPendingFeedings, removePendingFeeding, createFeeding),
drain(
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
createVarroaMeasurement,
),
drain(
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
createSwarmControlCheck,
),
drain(getPendingHarvests, removePendingHarvest, createHarvest),
]);
return results.reduce(
(total, result) => ({
synced: total.synced + result.synced,
failed: total.failed + result.failed,
}),
{ synced: 0, failed: 0 },
);
}