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:
1 parent
ac45514356
commit
1f67cd5548
11 files changed
+297
-46
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user