feat: Aufnahme und lokaler Speicher für Sprachnotizen (T7 L5 Task 17)

Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei
180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall
freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und
IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle
Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden
löscht nur die eigenen Notizen.

Refs #27

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 10:47:45 +02:00
1 parent 1dc39f7658
commit ce472164b5
6 files changed
+829 -11

No files matched your search

+69 -6
View File
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
it.each([
["a fresh install (0)", 0],
["version 1", 1],
])("opening from %s creates the plant report store at version 5", async (_name, from) => {
])("opening from %s creates the plant report store at version 6", async (_name, from) => {
if (from === 1) {
const old = await openDB(DB_NAME, 1, {
upgrade(database) {
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(upgraded.version).toBe(6);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(upgraded.version).toBe(6);
upgraded.close();
});
it("upgrading v2 → v5 keeps already queued inspections", async () => {
it("upgrading v2 → v6 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(upgraded.version).toBe(6);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
@@ -237,7 +237,7 @@ describe("offline queue database", () => {
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(upgraded.version).toBe(6);
expect(await upgraded.count("pending-inspections")).toBe(1);
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect([...upgraded.objectStoreNames]).toEqual(
@@ -246,6 +246,69 @@ describe("offline queue database", () => {
upgraded.close();
});
it("upgrading to the new version keeps queued inspections, photos and phenology reports", async () => {
const old = await openDB(DB_NAME, 5, {
upgrade(database) {
for (const store of [
...VERSION_2_STORES,
"pending-bulk",
"pending-phenology-reports",
"pending-hive-changes",
]) {
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
}
const photos = database.createObjectStore("pending-photos", { keyPath: "id" });
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await old.add("pending-inspections", { input: INSPECTION, ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z" });
await old.add("pending-phenology-reports", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T09:00:00.000Z" });
await old.add("pending-hive-changes", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T10:00:00.000Z" });
await old.put("pending-photos", photo("p1", "key-1"));
old.close();
const db = await loadDb();
expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(6);
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-hive-changes")).toBe(1);
expect(await upgraded.count("pending-photos")).toBe(1);
expect([...upgraded.objectStoreNames]).toContain("pending-voice");
expect([...upgraded.transaction("pending-voice").store.indexNames]).toEqual(["ownerUserId"]);
upgraded.close();
});
it("clearQueueOf removes one owner's voice notes and keeps everyone else's", async () => {
const db = await loadDb();
await db.getDb();
const raw = await openDB(DB_NAME);
const note = (id: string, ownerUserId: string) => ({
id,
ownerUserId,
blob: new Blob(["x"]),
mime: "audio/webm;codecs=opus",
durationSec: 5,
colonyId: "c-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: `u-${id}`,
state: "recorded",
});
await raw.put("pending-voice", note("v1", "user-1") as never);
await raw.put("pending-voice", note("v2", "user-2") as never);
await db.clearQueueOf("user-1");
expect(await raw.getAllKeys("pending-voice")).toEqual(["v2"]);
raw.close();
});
it("lists a hive change under its own name and marks inspections that carry one", async () => {
const db = await loadDb();
await db.queueHiveChange(