Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9cbcd8e43 | |||
| 282e7ba8ba | |||
| b86e5a15d6 | |||
| eac86ec655 | |||
| a6331bea1a |
@@ -2,7 +2,7 @@
|
||||
|
||||
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
|
||||
|
||||
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu) · **Demo:** [kapteins-daagbok.eu/demo](https://kapteins-daagbok.eu/demo)
|
||||
|
||||
## Überblick
|
||||
|
||||
@@ -15,19 +15,29 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
|
||||
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
|
||||
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
|
||||
- **Kompass-Dial** für MgK- und RwK-Kurse — Ring-Eingabe, Gradfeld, Schrittweite 1°/5°/10° (maritime Orientierung: 0° = Nord)
|
||||
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
|
||||
- **Foto-Anhänge** pro Reisetag
|
||||
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
|
||||
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
|
||||
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
|
||||
- **Benutzerprofil** — kontoweite Einstellungen: Darstellung (Theme, Hell/Dunkel), OpenWeatherMap-API-Key, Web Push, PWA-Installation, Onboarding-Tour, Passkey-Verwaltung, Account-Statistik
|
||||
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
|
||||
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
|
||||
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in im Benutzerprofil)
|
||||
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
|
||||
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
|
||||
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
|
||||
- **Feedback** — Bug-, Feature- und allgemeine Rückmeldungen aus der App (serverseitig via [Ntfy](https://ntfy.sh) oder self-hosted)
|
||||
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
|
||||
- **Mehrsprachig** — Deutsch und Englisch
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer (auch unter `/demo` ohne Anmeldung)
|
||||
|
||||
### Benutzerprofil vs. Logbuch-Einstellungen
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---------|--------|
|
||||
| **Benutzerprofil** | Theme, Farbschema, Wetter-API-Key, Push, PWA, Tour, Passkeys, Account löschen |
|
||||
| **Logbuch-Einstellungen** | Crew-Einladungen, öffentliche Freigabe, Backup & Wiederherstellung (nur Eigner) |
|
||||
|
||||
## Architektur
|
||||
|
||||
@@ -48,6 +58,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
|
||||
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
|
||||
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
|
||||
| Feedback (optional) | Ntfy (HTTP Publish) |
|
||||
|
||||
### Rollen & Zugriff
|
||||
|
||||
@@ -73,7 +84,7 @@ Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nic
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
|
||||
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
Nur der **Logbuch-Eigner** kann unter **Logbuch-Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
|
||||
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
|
||||
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
|
||||
@@ -83,7 +94,7 @@ Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einla
|
||||
|
||||
## Push-Benachrichtigungen (optional)
|
||||
|
||||
Logbuch-**Eigner** können unter **Einstellungen** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
|
||||
Logbuch-**Eigner** können im **Benutzerprofil** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
|
||||
|
||||
| Aspekt | Verhalten |
|
||||
|--------|-----------|
|
||||
@@ -102,20 +113,32 @@ Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichni
|
||||
|
||||
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
|
||||
|
||||
## Feedback (optional)
|
||||
|
||||
Eingeloggte Nutzer können über das Feedback-Formular in der App Rückmeldungen senden. Der Server leitet sie an einen **Ntfy**-Topic weiter (kein Klartext-Logbuch auf dem Server).
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `NTFY_SERVER` | Basis-URL (Standard: `https://ntfy.sh`) |
|
||||
| `NTFY_TOPIC` | Topic-Name (ohne URL) |
|
||||
| `NTFY_TOKEN` | Optional: Access-Token für geschützte Topics |
|
||||
|
||||
Ohne `NTFY_TOPIC` antwortet die API mit „nicht konfiguriert“. Rate-Limiting und einfacher Spam-Schutz sind serverseitig aktiv.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
kapteins-daagbok/
|
||||
├── client/ # React-PWA (Frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI-Komponenten
|
||||
│ │ ├── components/ # UI (u. a. CourseDialInput, UserProfilePage, FeedbackModal)
|
||||
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
|
||||
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
|
||||
│ │ └── i18n/ # DE/EN-Übersetzungen
|
||||
│ └── Dockerfile # Nginx-Produktions-Image
|
||||
├── server/ # Express-API + Prisma
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
|
||||
│ ├── src/services/ # z. B. pushNotify (Web Push)
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push, feedback, weather
|
||||
│ ├── src/services/ # z. B. pushNotify, ntfyNotify
|
||||
│ └── prisma/ # Datenbankschema
|
||||
├── docs/ # Projektdokumentation
|
||||
├── scripts/ # Dev- und Deploy-Skripte
|
||||
@@ -128,8 +151,9 @@ kapteins-daagbok/
|
||||
- **Node.js** 20+
|
||||
- **npm**
|
||||
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
|
||||
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
|
||||
- Optional: eigener OpenWeatherMap-API-Key im **Benutzerprofil** (sonst serverseitiger Key aus `.env`)
|
||||
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
|
||||
- Optional: Ntfy-Topic für Feedback (siehe Abschnitt Feedback)
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
@@ -166,6 +190,10 @@ SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
# Optional — Feedback via Ntfy
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=
|
||||
NTFY_TOKEN=
|
||||
```
|
||||
|
||||
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
|
||||
@@ -189,6 +217,15 @@ cd server && npx prisma db push && cd ..
|
||||
| Frontend (Vite) | http://localhost:5173 |
|
||||
| Backend API | http://localhost:5000 |
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
| Public Demo | http://localhost:5173/demo |
|
||||
|
||||
### 5. Tests (Frontend)
|
||||
|
||||
```bash
|
||||
cd client && npm test
|
||||
```
|
||||
|
||||
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
|
||||
|
||||
## Docker (produktionsnah)
|
||||
|
||||
@@ -198,9 +235,9 @@ Gesamten Stack lokal bauen und starten:
|
||||
./scripts/start-dev-docker.sh
|
||||
```
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
||||
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`).
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -212,7 +249,7 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
@@ -220,6 +257,7 @@ Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABA
|
||||
|----------|--------|
|
||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
|
||||
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
@@ -32,8 +31,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||
|
||||
// Reactive sync queue count
|
||||
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
|
||||
const { pendingCount, showSpinner, showPendingWarning } = useSyncIndicator()
|
||||
|
||||
// Listen to connectivity changes
|
||||
useEffect(() => {
|
||||
@@ -272,11 +270,27 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Connection Indicator */}
|
||||
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
|
||||
<div
|
||||
className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`}
|
||||
title={
|
||||
online
|
||||
? showSpinner
|
||||
? 'Syncing'
|
||||
: pendingCount > 0
|
||||
? 'Pending Sync'
|
||||
: 'Synced'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
{online ? (
|
||||
pendingCount > 0 ? (
|
||||
showSpinner ? (
|
||||
<>
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>{t('sync.status_syncing')}</span>
|
||||
</>
|
||||
) : showPendingWarning ? (
|
||||
<>
|
||||
<RefreshCw size={18} />
|
||||
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import {
|
||||
User,
|
||||
ChevronLeft,
|
||||
@@ -128,7 +129,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||
|
||||
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||
const { pendingCount: pendingSyncCount, showSpinner, showPendingWarning } = useSyncIndicator()
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
@@ -529,9 +530,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||
{online ? (
|
||||
pendingSyncCount > 0 ? (
|
||||
showSpinner ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||
<span>{t('sync.status_syncing')}</span>
|
||||
</>
|
||||
) : showPendingWarning ? (
|
||||
<>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { subscribeToSyncState } from '../services/sync.js'
|
||||
|
||||
/** Sync queue depth and whether a sync pass is running (for header indicators). */
|
||||
export function useSyncIndicator(logbookId?: string | null) {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
|
||||
const pendingCount =
|
||||
useLiveQuery(
|
||||
() =>
|
||||
logbookId
|
||||
? db.syncQueue.where({ logbookId }).count()
|
||||
: db.syncQueue.count(),
|
||||
[logbookId]
|
||||
) ?? 0
|
||||
|
||||
useEffect(() => subscribeToSyncState(setIsSyncing), [])
|
||||
|
||||
return {
|
||||
isSyncing,
|
||||
pendingCount,
|
||||
/** Spin only while a sync pass is active — not for stale queue counts. */
|
||||
showSpinner: isSyncing,
|
||||
showPendingWarning: pendingCount > 0 && !isSyncing
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_syncing": "Synchronisiere…",
|
||||
"status_offline": "Offline-Cache",
|
||||
"status_unsynced": "Unsynchronisierte Änderungen"
|
||||
},
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synced",
|
||||
"status_syncing": "Syncing…",
|
||||
"status_offline": "Offline Cache",
|
||||
"status_unsynced": "Unsynced changes"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
const pendingResync = new Set<string>()
|
||||
let syncAllInFlight = 0
|
||||
|
||||
let isSyncing = false
|
||||
const listeners = new Set<(syncing: boolean) => void>()
|
||||
@@ -18,7 +19,8 @@ export function subscribeToSyncState(listener: (syncing: boolean) => void) {
|
||||
}
|
||||
}
|
||||
|
||||
function setSyncing(syncing: boolean) {
|
||||
function recomputeSyncingState() {
|
||||
const syncing = syncingLogbooks.size > 0 || syncAllInFlight > 0
|
||||
if (isSyncing !== syncing) {
|
||||
isSyncing = syncing
|
||||
listeners.forEach((l) => l(isSyncing))
|
||||
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
return ok
|
||||
}
|
||||
|
||||
type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
/** Drop queue rows already reflected on the server (e.g. after direct API save). */
|
||||
async function pruneAcknowledgedQueueItems(
|
||||
logbookId: string,
|
||||
server: PulledServerPayload
|
||||
): Promise<void> {
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
if (pending.length === 0) return
|
||||
|
||||
const serverTimes = new Map<string, string>()
|
||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||
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 gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||
|
||||
const localLogbook = await db.logbooks.get(logbookId)
|
||||
const staleIds: number[] = []
|
||||
|
||||
for (const item of pending) {
|
||||
if (item.type === 'logbook') {
|
||||
if (localLogbook?.isSynced === 1) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleIds)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull updates from the server and apply last-write-wins
|
||||
async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
if (!localStorage.getItem('active_userid')) return false
|
||||
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
||||
|
||||
// 1. Sync Yacht Payload
|
||||
if (yacht) {
|
||||
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error during sync pull:', error)
|
||||
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
syncingLogbooks.add(logbookId)
|
||||
setSyncing(true)
|
||||
recomputeSyncingState()
|
||||
|
||||
try {
|
||||
const pushed = await flushPushQueue(logbookId)
|
||||
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
return pushed && pulled && pushedAfterPull
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
recomputeSyncingState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
syncAllInFlight++
|
||||
recomputeSyncingState()
|
||||
try {
|
||||
setSyncing(true)
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error('Error synchronizing all logbooks:', error)
|
||||
} finally {
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
syncAllInFlight = Math.max(0, syncAllInFlight - 1)
|
||||
recomputeSyncingState()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getColorSchemePreference,
|
||||
getOwmApiKey,
|
||||
getOwmApiKeyForActiveUser,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
@@ -33,6 +34,24 @@ describe('userPreferences', () => {
|
||||
expect(getOwmApiKey(USER_ID)).toBe('')
|
||||
})
|
||||
|
||||
it('reads namespaced OWM key via active user id', () => {
|
||||
setOwmApiKey(USER_ID, 'namespaced-only')
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
localStorage.removeItem('owm_api_key')
|
||||
|
||||
expect(getOwmApiKeyForActiveUser()).toBe('namespaced-only')
|
||||
expect(getOwmApiKey()).toBe('namespaced-only')
|
||||
})
|
||||
|
||||
it('does not read namespaced OWM key without active user id', () => {
|
||||
setOwmApiKey(USER_ID, 'namespaced-only')
|
||||
localStorage.removeItem('active_userid')
|
||||
localStorage.removeItem('owm_api_key')
|
||||
|
||||
expect(getOwmApiKeyForActiveUser()).toBe('')
|
||||
expect(getOwmApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('writes theme preferences to namespaced keys', () => {
|
||||
setThemePreference(USER_ID, 'ocean')
|
||||
setColorSchemePreference(USER_ID, 'light')
|
||||
|
||||
@@ -35,7 +35,7 @@ function migrateLegacyPrefs(userId: string): void {
|
||||
}
|
||||
|
||||
function resolveUserId(userId?: string | null): string | null {
|
||||
const id = userId ?? getActiveUserId()
|
||||
const id = (userId?.trim() || getActiveUserId()?.trim()) || null
|
||||
if (!id) return null
|
||||
migrateLegacyPrefs(id)
|
||||
return id
|
||||
@@ -75,6 +75,11 @@ export function getOwmApiKey(userId?: string | null): string {
|
||||
return localStorage.getItem(LEGACY_OWM_KEY) ?? ''
|
||||
}
|
||||
|
||||
/** OWM key for the signed-in user (`active_userid`). Prefer this over a bare `getOwmApiKey()` call. */
|
||||
export function getOwmApiKeyForActiveUser(): string {
|
||||
return getOwmApiKey(getActiveUserId())
|
||||
}
|
||||
|
||||
export function setOwmApiKey(userId: string, value: string): void {
|
||||
migrateLegacyPrefs(userId)
|
||||
const trimmed = value.trim()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch } from './api.js'
|
||||
import { getOwmApiKey } from './userPreferences.js'
|
||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
@@ -27,7 +27,7 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('lat/lon or location query required')
|
||||
}
|
||||
|
||||
const userKey = getOwmApiKey().trim()
|
||||
const userKey = getOwmApiKeyForActiveUser().trim()
|
||||
const headers: Record<string, string> = {}
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
|
||||
Reference in New Issue
Block a user