Add voice memos to live journal and event log.

Record short E2E-encrypted audio attachments from the live log, link them to events via __live:voice markers, and play them back in the stream and chronological event table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 14:52:12 +02:00
parent f83d67b527
commit 975c7a2e40
30 changed files with 1155 additions and 14 deletions
+2
View File
@@ -41,6 +41,8 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+1
View File
@@ -556,6 +556,7 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(),
db.entries.clear(),
db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear(),
+31
View File
@@ -65,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string
}
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack {
entryId: string // one track per daily journal entry
logbookId: string
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
| 'entry'
| 'logbook'
| 'photo'
| 'voiceMemo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(10).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
voiceMemos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+1
View File
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete()
await db.voiceMemos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete()
+73 -3
View File
@@ -63,6 +63,14 @@ export interface LogbookBackupFile {
tag: string
updatedAt: string
}>
voiceMemos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
@@ -74,6 +82,7 @@ export interface LogbookBackupFile {
counts: {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
hasYacht: boolean
@@ -128,6 +137,15 @@ async function unwrapLogbookKey(
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function normalizeBackupPayloads(
payloads: LogbookBackupFile['payloads']
): LogbookBackupFile['payloads'] {
return {
...payloads,
voiceMemos: payloads.voiceMemos ?? []
}
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
@@ -157,12 +175,13 @@ function encryptedPayloadData(
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
const [yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
@@ -205,6 +224,14 @@ async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupF
tag: p.tag,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
@@ -236,6 +263,7 @@ function remapBackup(
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
@@ -341,6 +369,19 @@ async function queueRestoredLogbookForSync(
})
}
for (const voice of payloads.voiceMemos ?? []) {
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(voice.encryptedData, voice.iv, voice.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of payloads.gpsTracks) {
items.push({
action: 'create',
@@ -434,6 +475,21 @@ async function writeBackupToDexie(
)
}
const voiceMemosToRestore = payloads.voiceMemos ?? []
if (voiceMemosToRestore.length > 0) {
await db.voiceMemos.bulkPut(
voiceMemosToRestore.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
}))
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
@@ -486,6 +542,7 @@ export async function exportLogbookBackup(
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
voiceMemos: payloads.voiceMemos?.length ?? 0,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
@@ -515,7 +572,14 @@ export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupF
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
return {
...parsed,
payloads: normalizeBackupPayloads(parsed.payloads),
counts: {
...parsed.counts,
voiceMemos: parsed.counts.voiceMemos ?? parsed.payloads.voiceMemos?.length ?? 0
}
}
}
export async function previewLogbookBackup(
@@ -572,7 +636,13 @@ export async function restoreLogbookBackup(
targetId = crypto.randomUUID()
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
const normalized = {
...backup,
payloads: normalizeBackupPayloads(backup.payloads)
}
const prepared = targetId === normalized.logbook.id
? normalized
: remapBackup(normalized, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey)
await queueRestoredLogbookForSync(
+38 -1
View File
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.entries.get(item.payloadId))
case 'photo':
return !!(await db.photos.get(item.payloadId))
case 'voiceMemo':
return !!(await db.voiceMemos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
@@ -230,6 +232,7 @@ type PulledServerPayload = {
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
}
@@ -253,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId)
@@ -299,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
crews,
entries,
photos,
voiceMemos,
gpsTracks
}
@@ -471,6 +476,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 5b. Sync Voice Memos
const serverVoiceMap = new Map<string, any>()
if (voiceMemos && Array.isArray(voiceMemos)) {
await forEachInBatches(voiceMemos, 20, async (v) => {
serverVoiceMap.set(v.payloadId, v)
const local = await db.voiceMemos.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.voiceMemos.put({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
})
}
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
for (const lv of localVoiceMemos) {
if (!serverVoiceMap.has(lv.payloadId)) {
const pendingCreate = await db.syncQueue
.where({ payloadId: lv.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.voiceMemos.delete(lv.payloadId)
}
}
}
// 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) {
+103
View File
@@ -0,0 +1,103 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryVoiceMemo(options: {
logbookId: string
entryId: string
audioDataUrl: string
mimeType: string
durationSec: number
caption?: string
analyticsContext?: string
}): Promise<string> {
const {
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption = '',
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
const voiceId = window.crypto.randomUUID()
const voicePayload = {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
}
const encrypted = await encryptJson(voicePayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
payloadId: voiceId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
const now = new Date().toISOString()
await db.voiceMemos.delete(voiceId)
await db.syncQueue.put({
action: 'delete',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest voice memo for an entry; returns its id or null. */
export async function removeLastVoiceMemoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const memos = await db.voiceMemos.where({ entryId }).toArray()
if (memos.length === 0) return null
memos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = memos[0].payloadId
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}