Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c91a0f1fc | |||
| 2bcbbba626 | |||
| b1500f8361 | |||
| bc7512003e | |||
| eaf126b584 | |||
| a9c712be45 | |||
| b0195601de | |||
| c2b58baa6e | |||
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc | |||
| 90518372d8 | |||
| 9d22cb61c7 |
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
|
|||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
|
import {
|
||||||
|
buildEntryListCache,
|
||||||
|
entryListItemFromLocal,
|
||||||
|
putEntryRecord
|
||||||
|
} from '../utils/entryListCache.js'
|
||||||
|
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
@@ -118,21 +124,31 @@ export default function LogEntriesList({
|
|||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
|
||||||
const list: DecryptedEntryItem[] = []
|
const list: DecryptedEntryItem[] = []
|
||||||
|
const needsDecrypt: typeof local = []
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
|
const cached = entryListItemFromLocal(entry)
|
||||||
|
if (cached) {
|
||||||
|
list.push(cached)
|
||||||
|
} else {
|
||||||
|
needsDecrypt.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||||
if (decrypted) {
|
if (!decrypted) return
|
||||||
|
|
||||||
|
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||||
list.push({
|
list.push({
|
||||||
id: entry.payloadId,
|
id: entry.payloadId,
|
||||||
date: decrypted.date || '',
|
...listCache,
|
||||||
dayOfTravel: decrypted.dayOfTravel || '',
|
updatedAt: entry.updatedAt
|
||||||
departure: decrypted.departure || '',
|
})
|
||||||
destination: decrypted.destination || '',
|
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
|
||||||
updatedAt: entry.updatedAt,
|
console.warn('Failed to persist entry list cache:', err)
|
||||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort chronological descending (by date, or dayOfTravel numerical)
|
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
@@ -309,14 +325,17 @@ export default function LogEntriesList({
|
|||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
// Save locally
|
// Save locally
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
|
{
|
||||||
payloadId: localId,
|
payloadId: localId,
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: encrypted.ciphertext,
|
encryptedData: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: nowStr
|
updatedAt: nowStr
|
||||||
})
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
// Queue for background sync
|
// Queue for background sync
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
|||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
@@ -454,14 +455,17 @@ export default function LogEntryEditor({
|
|||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
|
{
|
||||||
payloadId: entryId,
|
payloadId: entryId,
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: encrypted.ciphertext,
|
encryptedData: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
},
|
||||||
|
entryData
|
||||||
|
)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
enableCollaboratorChangePush,
|
enableCollaboratorChangePush,
|
||||||
fetchPushPrefs,
|
fetchPushPrefs,
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
isPushSupported
|
isPushSupported,
|
||||||
|
preloadPushService
|
||||||
} from '../services/pushNotifications.js'
|
} from '../services/pushNotifications.js'
|
||||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
try {
|
try {
|
||||||
const prefs = await fetchPushPrefs()
|
const prefs = await fetchPushPrefs()
|
||||||
setEnabled(prefs.collaboratorChangesEnabled)
|
setEnabled(prefs.collaboratorChangesEnabled)
|
||||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
|||||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
console.error('Failed to toggle push notifications:', err)
|
||||||
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
showAlert(message)
|
showAlert(message)
|
||||||
void loadPrefs()
|
void loadPrefs()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
|
|||||||
import {
|
import {
|
||||||
enableCollaboratorChangePush,
|
enableCollaboratorChangePush,
|
||||||
isCollaboratorPushActive,
|
isCollaboratorPushActive,
|
||||||
isPushSupported
|
isPushSupported,
|
||||||
|
preloadPushService
|
||||||
} from '../services/pushNotifications.js'
|
} from '../services/pushNotifications.js'
|
||||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
loadCollaborators()
|
loadCollaborators()
|
||||||
loadShareLink()
|
loadShareLink()
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
const loadShareLink = async () => {
|
const loadShareLink = async () => {
|
||||||
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to enable push after invite:', err)
|
console.error('Failed to enable push after invite:', err)
|
||||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
|
await showAlert(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
|
|||||||
|
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
checkForUpdate()
|
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||||
|
setTimeout(checkForUpdate, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOnline = () => {
|
const onOnline = () => {
|
||||||
checkForUpdate()
|
// Small delay to ensure connection is fully established
|
||||||
|
setTimeout(checkForUpdate, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ async function bootstrap(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js', { scope: '/' })
|
||||||
|
.then((reg) => {
|
||||||
|
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Service Worker registration failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
|
|||||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
input: string,
|
input: string,
|
||||||
init: RequestInit = {}
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(input, {
|
const controller = new AbortController()
|
||||||
...init,
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
headers,
|
|
||||||
credentials: 'include'
|
if (init.signal) {
|
||||||
})
|
if (init.signal.aborted) {
|
||||||
|
controller.abort()
|
||||||
|
} else {
|
||||||
|
init.signal.addEventListener('abort', () => controller.abort())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
try {
|
||||||
const res = await apiFetch(input, init)
|
return await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiJson<T>(
|
||||||
|
input: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await apiFetch(input, init, timeoutMs)
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryListCache {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalEntry {
|
export interface LocalEntry {
|
||||||
payloadId: string
|
payloadId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
|||||||
iv: string
|
iv: string
|
||||||
tag: string
|
tag: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||||
|
listCache?: EntryListCache
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalPhoto {
|
export interface LocalPhoto {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import { syncPersonPool } from './personPoolSync.js'
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
import type { PersonData } from '../types/person.js'
|
import type { PersonData } from '../types/person.js'
|
||||||
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
|
|||||||
const encrypted = await encryptJson(data, key)
|
const encrypted = await encryptJson(data, key)
|
||||||
|
|
||||||
if (type === 'entry') {
|
if (type === 'entry') {
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
|
{
|
||||||
payloadId,
|
payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: encrypted.ciphertext,
|
encryptedData: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
},
|
||||||
|
data as Record<string, unknown>
|
||||||
|
)
|
||||||
} else if (type === 'yacht') {
|
} else if (type === 'yacht') {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
|
|||||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
|||||||
return Notification.permission
|
return Notification.permission
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cachedVapidKey: string | null = null
|
||||||
|
let cachedRegistration: ServiceWorkerRegistration | null = null
|
||||||
|
|
||||||
|
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
throw new Error('Service Worker is not supported by your browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration()
|
||||||
|
if (reg) return reg
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to get service worker registration directly:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to waiting for ready state with a timeout
|
||||||
|
const readyPromise = navigator.serviceWorker.ready
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Promise.race([readyPromise, timeoutPromise])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadPushService(): Promise<void> {
|
||||||
|
if (!isPushSupported()) return
|
||||||
|
try {
|
||||||
|
if (!cachedVapidKey) {
|
||||||
|
await fetchVapidPublicKey()
|
||||||
|
}
|
||||||
|
if (!cachedRegistration) {
|
||||||
|
cachedRegistration = await getRegistrationCompat()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to preload push service:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||||
|
if (cachedVapidKey) return cachedVapidKey
|
||||||
|
|
||||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||||
if (typeof envKey === 'string' && envKey.trim()) {
|
if (typeof envKey === 'string' && envKey.trim()) {
|
||||||
return envKey.trim()
|
cachedVapidKey = envKey.trim()
|
||||||
|
return cachedVapidKey
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
if (typeof data.publicKey === 'string') {
|
||||||
|
cachedVapidKey = data.publicKey.trim()
|
||||||
|
return cachedVapidKey
|
||||||
|
}
|
||||||
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||||
|
if (typeof Notification === 'undefined') return 'denied'
|
||||||
|
|
||||||
|
// Try promise-based signature first
|
||||||
|
try {
|
||||||
|
const result = Notification.requestPermission()
|
||||||
|
if (result !== undefined) {
|
||||||
|
return await result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore and fall back to callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback-based fallback
|
||||||
|
return new Promise<NotificationPermission>((resolve) => {
|
||||||
|
Notification.requestPermission((permission) => {
|
||||||
|
resolve(permission)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const endpoint = subscription.endpoint
|
||||||
const json = subscription.toJSON()
|
const json = subscription.toJSON()
|
||||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
let p256dh = json.keys?.p256dh
|
||||||
|
let auth = json.keys?.auth
|
||||||
|
|
||||||
|
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
|
||||||
|
if (!p256dh && typeof subscription.getKey === 'function') {
|
||||||
|
try {
|
||||||
|
const rawKey = subscription.getKey('p256dh')
|
||||||
|
if (rawKey) {
|
||||||
|
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to extract p256dh key manually:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth && typeof subscription.getKey === 'function') {
|
||||||
|
try {
|
||||||
|
const rawAuth = subscription.getKey('auth')
|
||||||
|
if (rawAuth) {
|
||||||
|
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to extract auth key manually:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint || !p256dh || !auth) {
|
||||||
throw new Error('Invalid push subscription')
|
throw new Error('Invalid push subscription')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
|||||||
await apiJson(`${API_BASE}/subscription`, {
|
await apiJson(`${API_BASE}/subscription`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
endpoint: json.endpoint,
|
endpoint,
|
||||||
keys: json.keys,
|
keys: { p256dh, auth },
|
||||||
locale,
|
locale,
|
||||||
userAgent: navigator.userAgent
|
userAgent: navigator.userAgent
|
||||||
})
|
})
|
||||||
@@ -98,27 +193,35 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
throw new Error('Push notifications are not supported on this device')
|
throw new Error('Push notifications are not supported on this device')
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = await Notification.requestPermission()
|
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||||
if (permission !== 'granted') {
|
let registration = cachedRegistration
|
||||||
throw new Error('Notification permission denied')
|
if (!registration) {
|
||||||
|
registration = await getRegistrationCompat()
|
||||||
|
cachedRegistration = registration
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicKey = await fetchVapidPublicKey()
|
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
throw new Error('Push notifications are not configured on this server')
|
throw new Error('Push notifications are not configured on this server')
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
const permission = await requestNotificationPermission()
|
||||||
let subscription = await registration.pushManager.getSubscription()
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission denied')
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||||
const applicationServerKey = new Uint8Array(keyBytes)
|
const applicationServerKey = new Uint8Array(keyBytes)
|
||||||
subscription = await registration.pushManager.subscribe({
|
|
||||||
|
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||||
|
const subscribePromise = registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey
|
applicationServerKey
|
||||||
})
|
})
|
||||||
}
|
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||||
|
)
|
||||||
|
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||||
|
|
||||||
await saveSubscriptionToServer(subscription)
|
await saveSubscriptionToServer(subscription)
|
||||||
}
|
}
|
||||||
@@ -126,7 +229,12 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
export async function unsubscribeFromPush(): Promise<void> {
|
export async function unsubscribeFromPush(): Promise<void> {
|
||||||
if (!isPushSupported()) return
|
if (!isPushSupported()) return
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
let registration = cachedRegistration
|
||||||
|
if (!registration) {
|
||||||
|
registration = await getRegistrationCompat()
|
||||||
|
cachedRegistration = registration
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
if (!subscription) return
|
if (!subscription) return
|
||||||
|
|
||||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
|||||||
await savePushPrefs(false)
|
await savePushPrefs(false)
|
||||||
await unsubscribeFromPush()
|
await unsubscribeFromPush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPushSupported()) {
|
||||||
|
void preloadPushService()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson, encryptJson } from './crypto.js'
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import {
|
import {
|
||||||
buildLogEntryPayload,
|
buildLogEntryPayload,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
@@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
|
|
||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
|
{
|
||||||
payloadId: localId,
|
payloadId: localId,
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: encrypted.ciphertext,
|
encryptedData: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: nowStr
|
updatedAt: nowStr
|
||||||
})
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
@@ -305,14 +309,17 @@ async function persistEntry(
|
|||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
|
{
|
||||||
payloadId: entryId,
|
payloadId: entryId,
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: encrypted.ciphertext,
|
encryptedData: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
},
|
||||||
|
entryData
|
||||||
|
)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
|
|||||||
+19
-13
@@ -8,6 +8,7 @@ import {
|
|||||||
type SyncConflict
|
type SyncConflict
|
||||||
} from './syncConflicts.js'
|
} from './syncConflicts.js'
|
||||||
import { syncPersonPool } from './personPoolSync.js'
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -130,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleResync(logbookId: string) {
|
function scheduleResync(logbookId: string) {
|
||||||
if (pendingResync.has(logbookId)) return
|
|
||||||
pendingResync.add(logbookId)
|
pendingResync.add(logbookId)
|
||||||
queueMicrotask(() => {
|
|
||||||
pendingResync.delete(logbookId)
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||||
@@ -305,6 +301,10 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
|
|
||||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
||||||
await response.json()
|
await response.json()
|
||||||
|
|
||||||
|
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||||
|
await yieldToMain()
|
||||||
|
|
||||||
const serverSnapshot: PulledServerPayload = {
|
const serverSnapshot: PulledServerPayload = {
|
||||||
yacht,
|
yacht,
|
||||||
deviation,
|
deviation,
|
||||||
@@ -375,7 +375,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 3. Sync Crew List Payloads (legacy)
|
// 3. Sync Crew List Payloads (legacy)
|
||||||
const serverCrewMap = new Map<string, any>()
|
const serverCrewMap = new Map<string, any>()
|
||||||
if (crews && Array.isArray(crews)) {
|
if (crews && Array.isArray(crews)) {
|
||||||
for (const c of crews) {
|
await forEachInBatches(crews, 20, async (c) => {
|
||||||
serverCrewMap.set(c.payloadId, c)
|
serverCrewMap.set(c.payloadId, c)
|
||||||
const local = await db.crews.get(c.payloadId)
|
const local = await db.crews.get(c.payloadId)
|
||||||
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
||||||
@@ -388,7 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: c.updatedAt
|
updatedAt: c.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
||||||
@@ -408,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 4. Sync Journal Entry Payloads
|
// 4. Sync Journal Entry Payloads
|
||||||
const serverEntryMap = new Map<string, any>()
|
const serverEntryMap = new Map<string, any>()
|
||||||
if (entries && Array.isArray(entries)) {
|
if (entries && Array.isArray(entries)) {
|
||||||
for (const e of entries) {
|
await forEachInBatches(entries, 15, async (e) => {
|
||||||
serverEntryMap.set(e.payloadId, e)
|
serverEntryMap.set(e.payloadId, e)
|
||||||
const local = await db.entries.get(e.payloadId)
|
const local = await db.entries.get(e.payloadId)
|
||||||
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
||||||
@@ -421,7 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: e.updatedAt
|
updatedAt: e.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Entries
|
// Deletions for Entries
|
||||||
@@ -440,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 5. Sync Photos
|
// 5. Sync Photos
|
||||||
const serverPhotoMap = new Map<string, any>()
|
const serverPhotoMap = new Map<string, any>()
|
||||||
if (photos && Array.isArray(photos)) {
|
if (photos && Array.isArray(photos)) {
|
||||||
for (const p of photos) {
|
await forEachInBatches(photos, 20, async (p) => {
|
||||||
serverPhotoMap.set(p.payloadId, p)
|
serverPhotoMap.set(p.payloadId, p)
|
||||||
const local = await db.photos.get(p.payloadId)
|
const local = await db.photos.get(p.payloadId)
|
||||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||||
@@ -455,7 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: p.updatedAt
|
updatedAt: p.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Photos
|
// Deletions for Photos
|
||||||
@@ -474,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 6. Sync GPS Tracks
|
// 6. Sync GPS Tracks
|
||||||
const serverGpsTrackMap = new Map<string, any>()
|
const serverGpsTrackMap = new Map<string, any>()
|
||||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||||
for (const gt of gpsTracks) {
|
await forEachInBatches(gpsTracks, 10, async (gt) => {
|
||||||
serverGpsTrackMap.set(gt.entryId, gt)
|
serverGpsTrackMap.set(gt.entryId, gt)
|
||||||
const local = await db.gpsTracks.get(gt.entryId)
|
const local = await db.gpsTracks.get(gt.entryId)
|
||||||
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
||||||
@@ -487,7 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: gt.updatedAt
|
updatedAt: gt.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for GPS Tracks
|
// Deletions for GPS Tracks
|
||||||
@@ -535,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
|||||||
} finally {
|
} finally {
|
||||||
syncingLogbooks.delete(logbookId)
|
syncingLogbooks.delete(logbookId)
|
||||||
recomputeSyncingState()
|
recomputeSyncingState()
|
||||||
|
if (pendingResync.has(logbookId)) {
|
||||||
|
pendingResync.delete(logbookId)
|
||||||
|
setTimeout(() => {
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
|
||||||
|
import type { LocalEntry } from '../services/db.js'
|
||||||
|
|
||||||
|
describe('entryListCache', () => {
|
||||||
|
it('builds cache fields from decrypted entry', async () => {
|
||||||
|
const cache = await buildEntryListCache({
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '3',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
signSkipper: 'Max'
|
||||||
|
})
|
||||||
|
expect(cache).toEqual({
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '3',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
skipperSignStatus: 'valid'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps cached local entry to list item', () => {
|
||||||
|
const entry: LocalEntry = {
|
||||||
|
payloadId: 'e1',
|
||||||
|
logbookId: 'lb1',
|
||||||
|
encryptedData: 'x',
|
||||||
|
iv: 'i',
|
||||||
|
tag: 't',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||||
|
listCache: {
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
skipperSignStatus: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(entryListItemFromLocal(entry)).toEqual({
|
||||||
|
id: 'e1',
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||||
|
skipperSignStatus: 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when cache is missing', () => {
|
||||||
|
const entry: LocalEntry = {
|
||||||
|
payloadId: 'e1',
|
||||||
|
logbookId: 'lb1',
|
||||||
|
encryptedData: 'x',
|
||||||
|
iv: 'i',
|
||||||
|
tag: 't',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
expect(entryListItemFromLocal(entry)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
|
||||||
|
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
|
||||||
|
|
||||||
|
export type { EntryListCache }
|
||||||
|
|
||||||
|
export interface EntryListItem {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
updatedAt: string
|
||||||
|
skipperSignStatus: SkipperSignStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
|
||||||
|
return {
|
||||||
|
date: String(decrypted.date || ''),
|
||||||
|
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||||
|
departure: String(decrypted.departure || ''),
|
||||||
|
destination: String(decrypted.destination || ''),
|
||||||
|
skipperSignStatus: await getSkipperSignStatus(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
|
||||||
|
if (!entry.listCache) return null
|
||||||
|
return {
|
||||||
|
id: entry.payloadId,
|
||||||
|
date: entry.listCache.date,
|
||||||
|
dayOfTravel: entry.listCache.dayOfTravel,
|
||||||
|
departure: entry.listCache.departure,
|
||||||
|
destination: entry.listCache.destination,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
skipperSignStatus: entry.listCache.skipperSignStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
|
||||||
|
|
||||||
|
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
|
||||||
|
export async function putEntryRecord(
|
||||||
|
record: LocalEntryPut,
|
||||||
|
decryptedForCache?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const listCache =
|
||||||
|
record.listCache ??
|
||||||
|
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
|
||||||
|
|
||||||
|
await db.entries.put({
|
||||||
|
...record,
|
||||||
|
...(listCache ? { listCache } : {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
|
||||||
|
export function persistEntryListCache(
|
||||||
|
payloadId: string,
|
||||||
|
decrypted: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
void buildEntryListCache(decrypted)
|
||||||
|
.then((listCache) => db.entries.update(payloadId, { listCache }))
|
||||||
|
.catch((err) => console.warn('Failed to persist entry list cache:', err))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Yield so long tasks can interleave with paint and input handling. */
|
||||||
|
export function yieldToMain(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an async handler over items in batches, yielding between batches. */
|
||||||
|
export async function forEachInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
batchSize: number,
|
||||||
|
handler: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return
|
||||||
|
const size = Math.max(1, batchSize)
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
if (i > 0) await yieldToMain()
|
||||||
|
const batch = items.slice(i, i + size)
|
||||||
|
for (const item of batch) {
|
||||||
|
await handler(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user