Compare commits

...

14 Commits

Author SHA1 Message Date
elpatron 182ea497d8 chore: release v0.1.0.85 2026-06-01 19:43:24 +02:00
elpatron 837bcfe287 fix(ui): Disclaimer-Modal Scroll und Schließen-Button
Ein Scrollbereich im Hinweis-Dialog, Body-Lock beim Öffnen, X oben rechts an der Karte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:42:56 +02:00
elpatron d261a1e7ca i18n: Mannschaft und Äquivalente zu Crew umbenennen
Einheitliche Crew-Terminologie in allen App-Sprachen (de, en, nb, sv, da).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:38:46 +02:00
elpatron 2ebc3e8a44 chore: release v0.1.0.84 2026-06-01 19:34:10 +02:00
elpatron 047a5b1bdb fix(crew): keep first legacy skipper when several are present
Prefer canonical skipper id and stop overwriting activeSkipperId during
legacy crew migration and read-only share conversion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:34:01 +02:00
elpatron 7a7e9d5d28 chore: release v0.1.0.83 2026-06-01 19:31:20 +02:00
elpatron 39cbe707c7 fix(server): Docker build when prisma postinstall runs
Copy Prisma schema before npm ci in the builder image and skip
postinstall in the production stage since the client is copied from builder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:30:53 +02:00
elpatron bb6e7f5c32 chore: release v0.1.0.82 2026-06-01 19:29:51 +02:00
elpatron ca0daa8f2a fix(logs): repair journal entry cards and avoid duplicate days
Match dashboard card DOM so entry tiles get correct height, remove nested
form-card chrome, and open today’s entry instead of creating a second one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:27:09 +02:00
elpatron 2304f95ac1 fix(live-log): prevent freeze without GPS and prompt for day-start position
Harden geolocation with watchdog timeouts and permission checks so
desktop browsers without GPS no longer hang Live-Log. Show a hint to
log a position when none exists for the day.

Return 503 when crew-pool Prisma models are missing instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:20:34 +02:00
elpatron 98c0ed81d4 Raise Stammcrew pool limit from 5 to 12 crew members.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:07:37 +02:00
elpatron 3504ec97cc Add account-level crew pool with per-logbook and per-day selection.
Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:05:50 +02:00
elpatron 4c6c2779f2 chore: release v0.1.0.81 2026-06-01 18:35:35 +02:00
elpatron b6c4e9e7d9 fix(dashboard): allow spaces when renaming logbook title
Move the title input out of the card open button so Space no longer
activates navigation; use an overlay button for opening the logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 18:35:20 +02:00
45 changed files with 2578 additions and 255 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.81
0.1.0.86
+113 -15
View File
@@ -567,18 +567,16 @@ html.scheme-dark .themed-select-option.is-selected {
}
.registration-disclaimer--modal {
position: relative;
width: min(560px, calc(100vw - 32px));
margin: 0;
}
.registration-disclaimer .auth-header {
position: relative;
}
.registration-disclaimer__close {
position: absolute;
top: 0;
right: 0;
top: 12px;
right: 12px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -604,6 +602,7 @@ html.scheme-dark .themed-select-option.is-selected {
align-items: center;
justify-content: center;
padding: 16px;
overflow: hidden;
background: rgba(2, 6, 23, 0.72);
}
@@ -611,7 +610,20 @@ html.scheme-dark .themed-select-option.is-selected {
width: 100%;
max-width: 560px;
max-height: min(90vh, 820px);
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.disclaimer-modal-panel > .registration-disclaimer {
flex: 1 1 auto;
min-height: 0;
max-height: 100%;
width: 100%;
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.feedback-modal {
@@ -910,6 +922,7 @@ html.scheme-dark .themed-select-option.is-selected {
.registration-disclaimer__sections {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -1800,21 +1813,51 @@ html.scheme-dark .themed-select-option.is-selected {
}
.logbook-card-select {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 16px;
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
border: none;
border-radius: inherit;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.logbook-card > .card-icon,
.logbook-card > .card-info {
position: relative;
z-index: 1;
pointer-events: none;
}
.logbook-card > .card-info {
flex: 1;
min-width: 0;
}
.logbook-card-chevron {
position: relative;
z-index: 1;
flex-shrink: 0;
align-self: center;
margin-left: auto;
color: #475569;
pointer-events: none;
}
.logbook-card .logbook-title-editable,
.logbook-card .logbook-title-inline-edit,
.logbook-card .card-title-row {
pointer-events: auto;
}
.logbook-card--editing-title > .card-info {
pointer-events: auto;
}
.logbook-card {
background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop);
@@ -1930,6 +1973,8 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex;
align-items: center;
margin-top: -2px;
position: relative;
z-index: 2;
}
.logbook-card-actions .btn-delete {
@@ -3327,7 +3372,14 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-accent-light, #93c5fd);
}
.logs-journal {
width: 100%;
min-width: 0;
}
.live-log-card {
width: 100%;
min-width: 0;
min-height: 420px;
}
@@ -3337,6 +3389,31 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted);
}
.live-log-gps-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.45;
color: var(--app-text-muted);
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
}
.live-log-gps-hint svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--app-accent-light, #93c5fd);
}
.live-log-gps-hint-modal {
font-weight: 500;
color: var(--app-text, inherit);
}
.live-log-layout {
display: grid;
grid-template-columns: minmax(148px, 200px) 1fr;
@@ -5514,3 +5591,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
pointer-events: none;
}
.crew-selection-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.crew-selection-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
cursor: pointer;
}
.crew-selection-item input {
flex-shrink: 0;
}
+8 -5
View File
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
@@ -161,6 +163,7 @@ function App() {
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
}, [isAuthenticated])
useEffect(() => {
@@ -701,7 +704,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -752,10 +755,10 @@ function App() {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/>
)}
@@ -800,7 +803,7 @@ function App() {
type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={20} />
<span>{t('nav.crew')}</span>
+2 -1
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -603,7 +604,7 @@ export default function CrewForm({
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{!readOnly && crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
+23 -4
View File
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
activeCrewIds: logbookCrewSelection.activeCrewIds,
snapshotsById: Object.fromEntries(
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
id,
personToSnapshot(id, snap)
])
)
}
return (
<div className="app-layout">
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
<LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)}
</main>
</div>
+6 -1
View File
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open, onClose])
if (!open) return null
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
export interface EntryCrewSectionProps {
logbookId: string
readOnly?: boolean
value: EntryCrewFields
onChange: (next: EntryCrewFields) => void
/** Demo: fixed pool */
preloadedPool?: Map<string, PersonData>
}
export default function EntryCrewSection({
logbookId,
readOnly = false,
value,
onChange,
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
if (preloadedPool) {
setPool(preloadedPool)
return
}
let cancelled = false
void (async () => {
try {
const people = await loadPersonPool()
if (cancelled) return
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
} catch {
/* use snapshots only */
}
})()
return () => {
cancelled = true
}
}, [preloadedPool])
const displayPool = useMemo(() => {
const merged = new Map(pool)
for (const snap of Object.values(value.crewSnapshotsById)) {
if (!merged.has(snap.id)) {
merged.set(snap.id, {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
})
}
}
return merged
}, [pool, value.crewSnapshotsById])
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
const applyChange = (skipperId: string | null, crewIds: string[]) => {
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
onChange({
selectedSkipperId: skipperId,
selectedCrewIds: crewIds,
crewSnapshotsById: snapshots
})
}
const toggleCrew = (id: string) => {
if (readOnly) return
const next = value.selectedCrewIds.includes(id)
? value.selectedCrewIds.filter((x) => x !== id)
: [...value.selectedCrewIds, id]
applyChange(value.selectedSkipperId, next)
}
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</div>
)
}
export async function loadDefaultEntryCrewForNewDay(
logbookId: string,
previousEntry: Record<string, unknown> | null
): Promise<EntryCrewFields> {
if (previousEntry) {
const selectedSkipperId =
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
const selection = await loadLogbookCrewSelection(logbookId)
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
+95 -19
View File
@@ -52,7 +52,11 @@ import {
} from '../utils/liveEventCodes.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
import {
getCurrentPosition,
normalizeGpsCoordinates,
queryGeolocationPermission
} from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
@@ -167,11 +171,13 @@ export default function LiveLogView({
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy)
const initSeqRef = useRef(0)
const eventsRef = useRef(events)
const dateRef = useRef(date)
eventsRef.current = events
dateRef.current = date
busyRef.current = busy
const defaultSails = useMemo(
() => (i18n.language === 'de'
@@ -185,6 +191,10 @@ export default function LiveLogView({
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false),
[events, date]
)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -276,7 +286,9 @@ export default function LiveLogView({
return () => {
initSeqRef.current += 1
}
}, [runInit])
// Only re-init when the logbook changes — not when i18n `t` identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
}, [logbookId])
useEffect(() => {
if (!loading && entryId) {
@@ -297,15 +309,34 @@ export default function LiveLogView({
useEffect(() => {
if (!entryId || loading) return
let cancelled = false
let startTimer: number | undefined
let intervalRef: number | undefined
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
if (
cancelled
|| document.visibilityState !== 'visible'
|| autoPositionBusyRef.current
|| busyRef.current
) {
return
}
const permission = await queryGeolocationPermission()
if (cancelled || permission !== 'granted') return
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
try {
const coords = await getCurrentPosition(8000)
const coords = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 120_000
})
if (cancelled || busyRef.current) return
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
@@ -313,23 +344,26 @@ export default function LiveLogView({
})
await refreshEntry(entryId)
} catch {
// Silent — auto-position is best-effort
// Best-effort; hint banner shows when no position fix exists yet.
} finally {
autoPositionBusyRef.current = false
}
}
let intervalRef: number | undefined
const startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
void queryGeolocationPermission().then((permission) => {
if (cancelled || permission !== 'granted') return
startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
})
return () => {
window.clearTimeout(startTimer)
cancelled = true
if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef)
}
}, [entryId, loading, logbookId, refreshEntry, busy])
}, [entryId, loading, logbookId, refreshEntry])
const runQuickAction = async (
action: () => Promise<boolean | void>,
@@ -364,8 +398,15 @@ export default function LiveLogView({
const openSogModal = async () => {
let prefill = ''
try {
const pos = await getCurrentPosition()
if (pos.speedKn != null) prefill = String(pos.speedKn)
const permission = await queryGeolocationPermission()
if (permission === 'granted') {
const pos = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
})
if (pos.speedKn != null) prefill = String(pos.speedKn)
}
} catch {
// Manual entry when GPS speed unavailable
}
@@ -405,7 +446,16 @@ export default function LiveLogView({
setFixGpsLoading(true)
setModal('fix')
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
@@ -419,12 +469,28 @@ export default function LiveLogView({
setFixGpsLoading(true)
setFixGpsUnavailable(false)
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
} finally {
setFixGpsLoading(false)
}
@@ -757,7 +823,7 @@ export default function LiveLogView({
return (
<>
<div className="form-card live-log-card">
<div className="live-log-card">
<div className="section-title-bar mb-4">
<div className="form-header" style={{ margin: 0 }}>
<Radio size={24} className="form-icon" />
@@ -786,6 +852,13 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && (
<p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')}
</p>
)}
<div className="live-log-layout">
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
@@ -974,7 +1047,10 @@ export default function LiveLogView({
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
<>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
</>
)}
<fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
+30 -6
View File
@@ -9,6 +9,7 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -239,6 +240,12 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const existingTodayId = await findTodayEntryId(logbookId)
if (existingTodayId) {
setSelectedEntryId(existingTodayId)
return
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
@@ -277,6 +284,12 @@ export default function LogEntriesList({
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
logbookId,
previousEntry as Record<string, unknown> | null
)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
@@ -285,6 +298,9 @@ export default function LogEntriesList({
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '',
signCrew: '',
events: []
@@ -381,7 +397,10 @@ export default function LogEntriesList({
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => setViewMode('list')}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
)
}
@@ -401,7 +420,7 @@ export default function LogEntriesList({
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="logs-journal">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
@@ -466,8 +485,14 @@ export default function LogEntriesList({
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
}
/>
<div className="card-icon" aria-hidden>
<FileText size={24} />
</div>
@@ -488,8 +513,7 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
</button>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
+20 -3
View File
@@ -11,6 +11,9 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx'
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -108,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
})
return JSON.stringify({
@@ -168,6 +172,8 @@ export default function LogEntryEditor({
const [greywaterLevel, setGreywaterLevel] = useState('0')
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
// Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
@@ -279,7 +285,8 @@ export default function LogEntryEditor({
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events
events: eventsOverride ?? events,
entryCrew
})
}, [
date, dayOfTravel, departure, destination,
@@ -287,7 +294,8 @@ export default function LogEntryEditor({
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events
events,
entryCrew
])
useEffect(() => {
@@ -706,6 +714,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
@@ -744,6 +753,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
@@ -2032,6 +2042,13 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
<EntryCrewSection
logbookId={logbookId}
readOnly={readOnly}
value={entryCrew}
onChange={setEntryCrew}
/>
<SignatureSection
readOnly={readOnly}
disabled={saving}
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Save, Check } from 'lucide-react'
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
import type { DecryptedPerson } from '../services/personPool.js'
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookCrewPickerProps {
logbookId: string
readOnly?: boolean
/** Demo / share: in-memory pool */
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
preloadedSelection?: LogbookCrewSelectionData
/** Shared logbook: only people from selection snapshots */
selectionOnly?: boolean
}
function snapshotsToPoolList(
selection: LogbookCrewSelectionData
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
return Object.values(selection.snapshotsById).map((snap) => ({
payloadId: snap.id,
data: {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
}
}))
}
export default function LogbookCrewPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false
}: LogbookCrewPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedPerson[]>([])
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
if (selection) {
setActiveSkipperId(selection.activeSkipperId)
setActiveCrewIds([...selection.activeCrewIds])
}
if (preloadedPool) {
setPool(
preloadedPool.map((p) => ({
payloadId: p.payloadId,
data: p.data
}))
)
} else if (selectionOnly && selection) {
setPool(snapshotsToPoolList(selection))
} else {
setPool(await loadPersonPool())
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const skippers = useMemo(() => filterSkippers(pool), [pool])
const crewMembers = useMemo(() => filterCrew(pool), [pool])
const toggleCrew = (id: string) => {
if (readOnly) return
setActiveCrewIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
<div className="form-card">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('logbook_crew.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_crew.active_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
) : (
<div className="crew-selection-list">
{skippers.map((s) => (
<label key={s.payloadId} className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === s.payloadId}
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
disabled={readOnly}
/>
<User size={16} aria-hidden="true" />
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === null}
onChange={() => setActiveSkipperId(null)}
/>
<span>{t('logbook_crew.no_skipper')}</span>
</label>
)}
</div>
)}
</div>
<div className="input-group mb-4">
<label>{t('logbook_crew.active_crew')}</label>
{crewMembers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
) : (
<div className="crew-selection-list">
{crewMembers.map((c) => (
<label key={c.payloadId} className="crew-selection-item">
<input
type="checkbox"
checked={activeCrewIds.includes(c.payloadId)}
onChange={() => toggleCrew(c.payloadId)}
disabled={readOnly}
/>
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_crew.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_crew.save')}
</button>
</div>
)}
</div>
</div>
)
}
export function selectionFromSnapshots(
snapshotsById: Record<string, PersonSnapshot>
): LogbookCrewSelectionData {
const snapshots = Object.values(snapshotsById)
const skipper = snapshots.find((s) => s.role === 'skipper')
return {
activeSkipperId: skipper?.id ?? null,
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
snapshotsById
}
}
+11 -9
View File
@@ -226,14 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
>
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
>
<div className="card-icon">
{!isEditingTitle && (
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} />
</div>
@@ -246,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -287,7 +290,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
</span>
</div>
</div>
</button>
{!lb.isShared && (
<div className="logbook-card-actions">
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { resizeImageFile } from '../utils/resizeImageFile.js'
import type { PersonData, PersonRole } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import {
loadPersonPool,
savePerson,
deletePerson,
filterSkippers,
filterCrew,
type DecryptedPerson
} from '../services/personPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const emptyPerson = (role: PersonRole): PersonData => ({
name: '',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role,
photo: null
})
export default function PersonPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [people, setPeople] = useState<DecryptedPerson[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formRole, setFormRole] = useState<PersonRole>('crew')
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPeople(await loadPersonPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = (role: PersonRole) => {
setEditingId(null)
setFormRole(role)
setForm(emptyPerson(role))
setPhotoError(null)
setShowForm(true)
}
const openEdit = (person: DecryptedPerson) => {
setEditingId(person.payloadId)
setFormRole(person.data.role)
setForm({ ...person.data })
setPhotoError(null)
setShowForm(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const id = editingId ?? window.crypto.randomUUID()
await savePerson(id, { ...form, role: formRole }, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_CREW') {
setError(t('crew.max_crew'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('person_pool.delete_confirm'),
t('person_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deletePerson(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
const skippers = filterSkippers(people)
const crewList = filterCrew(people)
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
const renderCard = (person: DecryptedPerson) => (
<div key={person.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{person.data.photo ? (
<img src={person.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{person.data.name}</h4>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{person.data.phone && (
<p className="help-text">
<strong>{t('crew.phone')}:</strong> {person.data.phone}
</p>
)}
</div>
)
return (
<section className="form-card" data-tour="profile-crew-pool">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('person_pool.title')}</h2>
</div>
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.skippers_section')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
<Plus size={16} />
{t('person_pool.add_skipper')}
</button>
)}
</div>
{skippers.length === 0 ? (
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
) : (
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
)}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.crew_section')}</h3>
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
<Plus size={16} />
{t('person_pool.add_crew')}
</button>
)}
</div>
{crewList.length === 0 ? (
<p className="help-text">{t('person_pool.no_crew')}</p>
) : (
<div className="crew-grid">{crewList.map(renderCard)}</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
<div className="editor-header mb-4">
<h3>
{editingId
? formRole === 'skipper'
? t('person_pool.edit_skipper')
: t('crew.edit_crew')
: formRole === 'skipper'
? t('person_pool.add_skipper')
: t('crew.add_crew')}
</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
{form.photo ? (
<img src={form.photo} alt="" className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
void resizeImageFile(file)
.then((photo) => setForm((f) => ({ ...f, photo })))
.catch((err: unknown) => {
setPhotoError(err instanceof Error ? err.message : 'Image error')
})
}}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('crew.name')} *</label>
<input
className="input-text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="input-group">
<label>{t('crew.address')}</label>
<input
className="input-text"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.birthdate')}</label>
<input
type="date"
className="input-text"
value={form.birthDate}
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.phone')}</label>
<input
className="input-text"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.nationality')}</label>
<input
className="input-text"
value={form.nationality}
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.passport')}</label>
<input
className="input-text"
value={form.passportNumber}
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
</div>
<div className="editor-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
<Save size={18} />
{t('crew.save_member')}
</button>
</div>
</form>
)}
</section>
)
}
+46 -13
View File
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
@@ -71,18 +78,42 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setYacht(decYacht)
// Decrypt Crews
const decCrews = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
decCrews.push({
payloadId: c.payloadId,
data: dec
if (data.logbookCrewSelection) {
const decSel = await decryptJson(
data.logbookCrewSelection.encryptedData,
data.logbookCrewSelection.iv,
data.logbookCrewSelection.tag,
keyBuffer
)
if (decSel) {
setLogbookCrewSelection({
activeSkipperId: decSel.activeSkipperId ?? null,
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
snapshotsById:
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
? decSel.snapshotsById
: {}
})
}
}
setCrews(decCrews)
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
if (dec) {
decCrews.push({
payloadId: c.payloadId,
data: dec as PersonData
})
}
}
}
setLegacyCrews(decCrews)
if (!data.logbookCrewSelection && decCrews.length > 0) {
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
}
// Decrypt Entries
const decEntries = []
@@ -234,10 +265,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId="shared"
readOnly={true}
preloadedData={crews}
selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/>
)}
</main>
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document"
>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
<div className="auth-header">
<ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
</div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
@@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} />
</div>
<PersonPoolForm />
<section className="member-editor-card glass">
<div className="profile-section-header">
<Shield size={20} />
+3 -1
View File
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toHaveLength(12)
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER).toHaveLength(13)
})
it('excludes profile, stats and feedback from demo tour', () => {
+19 -6
View File
@@ -26,7 +26,8 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'nav_logbook_crew',
'nav_stats',
'nav_feedback'
])
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
return 250
}
return 0
}
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
if (stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
if (stepId === 'nav_logbook_crew') {
nav.setSelectedEntryId(null)
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
+75 -34
View File
@@ -33,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Skibsdata",
"crew": "Besætningsliste",
"crew": "Crew",
"deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen",
"stats": "Statistik",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skipper har udgivet",
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.",
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
"sign_lock_warning_title": "Bekræft underskrift",
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
"sign_proceed": "Tegn",
"sign_cancel": "Annuller",
"sign_cleared_re_sign_title": "Underskrifter fjernet",
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
"back_to_list": "Tilbage til tidsskriftslisten",
"save": "Gem logbogsside",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
@@ -386,12 +387,12 @@
"track_map_error": "Kortet kunne ikke indlæses.",
"exporting": "Eksport...",
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
"invite_crew": "Inviter besætningen",
"invite_crew": "Inviter crewen",
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / besætning",
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / crew",
"revoke": "Fjerne",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
"invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Slet logbog",
"section_owned": "Mine logbøger",
"section_shared": "Fælles logbøger",
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
"role_owner": "Egen logbog",
"role_owner_hint": "Du er ejer og skipper af denne logbog",
"role_crew": "Adgang for besætning",
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
"role_crew": "Adgang for crew",
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
"role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes",
@@ -598,23 +600,58 @@
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
"tour_restart": "Start turen igen",
"push_title": "Push-meddelelser",
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
"push_enable": "Giv os besked om ændringer i besætningen",
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
"push_enable": "Giv os besked om ændringer i crewen",
"push_active": "Push-meddelelser er aktive på denne enhed.",
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
"push_error": "Push-meddelelser kunne ikke aktiveres."
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Administrer din personpulje her skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
"loading": "Indlæser personpulje…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Tilføj skipper",
"add_crew": "Tilføj Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i puljen endnu.",
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
"delete_confirm": "Fjern denne person fra puljen?"
},
"logbook_crew": {
"title": "Crew for denne logbog",
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
"loading": "Indlæser crew…",
"active_skipper": "Skipper for denne logbog",
"active_crew": "Crew for denne logbog",
"no_skippers_in_pool": "Ingen skipper i puljen tilføj i brugerprofilen først.",
"no_crew_in_pool": "Ingen crew i puljen tilføj i brugerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uden navn",
"save": "Gem crew",
"saved": "Logbog-Crew gemt.",
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
},
"entry_crew": {
"title": "Crew på denne rejsedag",
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
"day_skipper": "Skipper denne dag",
"day_crew": "Crew denne dag",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og besætningsprofiler",
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipper-profil",
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
"crew_section": "Besætningsliste",
"add_crew": "Tilføj besætningsmedlem",
"edit_crew": "Rediger besætningsmedlem",
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
"crew_section": "Crew-liste",
"add_crew": "Tilføj Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
"name": "Navn",
"address": "adresse",
"birthdate": "Fødselsdag",
@@ -627,8 +664,8 @@
"save": "Gem skipper-data",
"save_member": "Gem medlem",
"saved": "Skipperprofilen er blevet gemt!",
"loading": "Besætningsfilerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
"loading": "Crew-filerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
},
"deviation": {
"title": "Tabel over kompasafvigelser",
@@ -650,7 +687,7 @@
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
"share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"delete_account_btn": "Slet konto uigenkaldeligt",
"delete_account_confirm_title": "Slette konto?",
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
@@ -668,13 +705,13 @@
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
"deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?",
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
"invite_push_prompt_enable": "Aktiver nu",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
"backup_title": "Sikkerhedskopiering og gendannelse",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_export_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi",
@@ -704,7 +741,7 @@
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
"backup_stat_entries": "{{count}} Rejsedage",
"backup_stat_photos": "{{count}} Fotos",
"backup_stat_crew": "{{count}} Besætningens poster",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksporteret: {{date}}"
},
@@ -846,7 +883,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, crew og logbogsposter."
},
"nav_logs": {
"title": "Indlæg i logbogen",
@@ -868,9 +905,13 @@
"title": "Skibsdata",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
},
"nav_crew": {
"title": "Besætningsliste",
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brugerprofilen vedligeholder du en personpulje flere skippere (f.eks. charter) og crew til alle logbøger."
},
"nav_logbook_crew": {
"title": "Crew per logbog",
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
},
"nav_stats": {
"title": "Statistik-dashboard",
@@ -896,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.",
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
+47 -6
View File
@@ -33,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
"crew": "Crew-Liste",
"crew": "Crew",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
@@ -468,6 +469,7 @@
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
@@ -606,6 +608,41 @@
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
},
"person_pool": {
"title": "Stammcrew & Skipper",
"subtitle": "Lege hier deinen Personen-Pool an Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
"loading": "Personen-Pool wird geladen…",
"skippers_section": "Stammskipper",
"crew_section": "Stammcrew",
"add_skipper": "Skipper hinzufügen",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_skipper": "Skipper bearbeiten",
"no_skippers": "Noch kein Skipper im Pool.",
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
},
"logbook_crew": {
"title": "Crew für dieses Logbuch",
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
"loading": "Crew wird geladen…",
"active_skipper": "Skipper für dieses Logbuch",
"active_crew": "Crew für dieses Logbuch",
"no_skippers_in_pool": "Kein Skipper im Pool zuerst im Benutzerprofil anlegen.",
"no_crew_in_pool": "Keine Crew im Pool zuerst im Benutzerprofil anlegen.",
"no_skipper": "Kein Skipper gewählt",
"unnamed": "Unbenannt",
"save": "Crew speichern",
"saved": "Crew für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
},
"entry_crew": {
"title": "Crew an diesem Reisetag",
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
"day_skipper": "Skipper an diesem Tag",
"day_crew": "Crew an diesem Tag",
"no_skipper": "Kein Skipper gewählt",
"no_crew": "Keine Crew gewählt"
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
@@ -614,7 +651,7 @@
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
"name": "Name",
"address": "Anschrift",
"birthdate": "Geburtstag",
@@ -846,7 +883,7 @@
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Crew-Auswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
@@ -868,9 +905,13 @@
"title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
},
"nav_logbook_crew": {
"title": "Crew pro Logbuch",
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
},
"nav_stats": {
"title": "Statistik-Dashboard",
+46 -5
View File
@@ -33,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
"crew": "Crew List",
"crew": "Crew",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
@@ -468,6 +469,7 @@
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
@@ -606,6 +608,41 @@
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
},
"person_pool": {
"title": "Core Crew & skippers",
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
"loading": "Loading person pool…",
"skippers_section": "Skippers",
"crew_section": "Core Crew",
"add_skipper": "Add skipper",
"add_crew": "Add crew member",
"edit_skipper": "Edit skipper",
"no_skippers": "No skippers in the pool yet.",
"no_crew": "No crew members in the pool yet.",
"delete_confirm": "Remove this person from the pool?"
},
"logbook_crew": {
"title": "Crew for this logbook",
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
"loading": "Loading crew…",
"active_skipper": "Skipper for this logbook",
"active_crew": "Crew for this logbook",
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
"no_skipper": "No skipper selected",
"unnamed": "Unnamed",
"save": "Save crew",
"saved": "Logbook crew saved.",
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
},
"entry_crew": {
"title": "Crew on this travel day",
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
"day_skipper": "Skipper on this day",
"day_crew": "Crew on this day",
"no_skipper": "No skipper selected",
"no_crew": "No crew selected"
},
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
@@ -614,7 +651,7 @@
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.",
"max_crew": "Maximum of 5 crew members reached.",
"max_crew": "Maximum of 12 crew members in the pool reached.",
"name": "Full Name",
"address": "Address",
"birthdate": "Date of Birth",
@@ -868,9 +905,13 @@
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
"profile_crew_pool": {
"title": "Core Crew & skippers",
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
},
"nav_logbook_crew": {
"title": "Crew per logbook",
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
},
"nav_stats": {
"title": "Statistics dashboard",
+76 -35
View File
@@ -33,7 +33,7 @@
"nav": {
"dashboard": "Dashbord",
"vessel": "Skipsdata",
"crew": "Mannskapsliste",
"crew": "Crew",
"deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer",
"stats": "Statistikk",
@@ -183,7 +183,7 @@
"consumption": "Daglig forbruk",
"signatures": "Underskrifter / frigivelse",
"sign_skipper": "Skippers signatur",
"sign_crew": "Mannskapets signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signer med finger, penn eller mus",
"sign_clear": "Slett",
"sign_export_image": "[Signatur]",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.",
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
"sign_lock_warning_title": "Bekreft signatur",
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?",
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
"sign_proceed": "Skilt",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Signaturer fjernet",
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
"back_to_list": "Tilbake til tidsskriftlisten",
"save": "Lagre loggbokside",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
@@ -386,12 +387,12 @@
"track_map_error": "Kartet kunne ikke lastes inn.",
"exporting": "Eksport...",
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
"invite_crew": "Inviter mannskapet",
"invite_crew": "Inviter crewet",
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Besetning",
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Crew",
"revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
"invite_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Slett loggbok",
"section_owned": "Loggbøkene mine",
"section_shared": "Felles loggbøker",
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du er eier og skipper av denne loggboken",
"role_crew": "Tilgang for mannskapet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
"role_crew": "Tilgang for crewet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
"role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn",
@@ -598,23 +600,58 @@
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
"tour_restart": "Start turen på nytt",
"push_title": "Push-varsler",
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
"push_enable": "Gi oss beskjed om endringer i mannskapet",
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
"push_enable": "Gi oss beskjed om endringer i crewet",
"push_active": "Push-varsler er aktive på denne enheten.",
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres."
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Hold personpoolen din her skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
"loading": "Laster personpool…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Legg til skipper",
"add_crew": "Legg til Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i poolen ennå.",
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
"delete_confirm": "Fjerne denne personen fra poolen?"
},
"logbook_crew": {
"title": "Crew for denne loggboken",
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
"loading": "Laster crew…",
"active_skipper": "Skipper for denne loggboken",
"active_crew": "Crew for denne loggboken",
"no_skippers_in_pool": "Ingen skipper i poolen legg til i brukerprofilen først.",
"no_crew_in_pool": "Ingen crew i poolen legg til i brukerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uten navn",
"save": "Lagre crew",
"saved": "Loggbok-Crew lagret.",
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
},
"entry_crew": {
"title": "Crew på denne reisedagen",
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
"day_skipper": "Skipper denne dagen",
"day_crew": "Crew denne dagen",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og mannskapsprofiler",
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipperprofil",
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
"crew_section": "Mannskapsliste",
"add_crew": "Legg til besetningsmedlem",
"edit_crew": "Rediger besetningsmedlem",
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
"crew_section": "Crew-liste",
"add_crew": "Legg til Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
"name": "Navn",
"address": "adresse",
"birthdate": "Bursdag",
@@ -627,8 +664,8 @@
"save": "Lagre skipperdata",
"save_member": "Lagre medlem",
"saved": "Skipperprofilen er vellykket lagret!",
"loading": "Mannskapsfilene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
"loading": "Crew-filene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
},
"deviation": {
"title": "Tabell over kompassavvik",
@@ -650,7 +687,7 @@
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"delete_account_btn": "Slett konto ugjenkallelig",
"delete_account_confirm_title": "Slett konto?",
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
@@ -668,13 +705,13 @@
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
"deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?",
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
"invite_push_prompt_enable": "Aktiver nå",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
"backup_title": "Sikkerhetskopiering og gjenoppretting",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_export_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi",
@@ -704,7 +741,7 @@
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
"backup_stat_entries": "{{count}} Reisedager",
"backup_stat_photos": "{{count}} Bilder",
"backup_stat_crew": "{{count}} Mannskapsposter",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksportert: {{date}}"
},
@@ -846,7 +883,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, crew og loggbokoppføringer."
},
"nav_logs": {
"title": "Loggbokoppføringer",
@@ -868,9 +905,13 @@
"title": "Skipsdata",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
},
"nav_crew": {
"title": "Mannskapsliste",
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brukerprofilen vedlikeholder du en personpool flere skippere (f.eks. charter) og crew for alle loggbøker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
},
"nav_stats": {
"title": "Dashbord for statistikk",
@@ -896,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
+77 -36
View File
@@ -33,7 +33,7 @@
"nav": {
"dashboard": "Instrumentpanel",
"vessel": "Fartygsdata",
"crew": "Besättningslista",
"crew": "Crew",
"deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar",
"stats": "Statistik",
@@ -183,7 +183,7 @@
"consumption": "Daglig konsumtion",
"signatures": "Underskrifter / frisläppande",
"sign_skipper": "Skepparens signatur",
"sign_crew": "Besättningens signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signera med finger, penna eller mus",
"sign_clear": "Radera",
"sign_export_image": "[Signatur]",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skepparen har släppt",
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.",
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
"sign_lock_warning_title": "Bekräfta underskrift",
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
"sign_proceed": "Teckna",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Underskrifter borttagna",
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
"back_to_list": "Tillbaka till tidskriftslistan",
"save": "Spara loggbokssida",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
@@ -386,12 +387,12 @@
"track_map_error": "Kartan kunde inte läsas in.",
"exporting": "Export...",
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
"invite_crew": "Bjud in besättningen",
"invite_crew": "Bjud in crewen",
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Besättning",
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Crew",
"revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
"invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Radera loggbok",
"section_owned": "Mina loggböcker",
"section_shared": "Delade loggböcker",
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
"role_crew": "Tillträde för besättningen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
"role_crew": "Tillträde för crewen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
"role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn",
@@ -598,23 +600,58 @@
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
"tour_restart": "Starta resan igen",
"push_title": "Push-meddelanden",
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
"push_enable": "Meddela oss om förändringar i besättningen",
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
"push_enable": "Meddela oss om förändringar i crewen",
"push_active": "Push-meddelanden är aktiva på den här enheten.",
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
"push_error": "Push-meddelanden kunde inte aktiveras."
},
"person_pool": {
"title": "Stamm-Crew och skeppare",
"subtitle": "Underhåll din personpool här skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
"loading": "Laddar personpool…",
"skippers_section": "Skeppare",
"crew_section": "Stamm-Crew",
"add_skipper": "Lägg till skeppare",
"add_crew": "Lägg till Crew-medlem",
"edit_skipper": "Redigera skeppare",
"no_skippers": "Ingen skeppare i poolen ännu.",
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
"delete_confirm": "Ta bort denna person från poolen?"
},
"logbook_crew": {
"title": "Crew för denna loggbok",
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
"loading": "Laddar crew…",
"active_skipper": "Skeppare för denna loggbok",
"active_crew": "Crew för denna loggbok",
"no_skippers_in_pool": "Ingen skeppare i poolen lägg till i användarprofilen först.",
"no_crew_in_pool": "Ingen crew i poolen lägg till i användarprofilen först.",
"no_skipper": "Ingen skeppare vald",
"unnamed": "Namnlös",
"save": "Spara crew",
"saved": "Loggbok-Crew sparad.",
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
},
"entry_crew": {
"title": "Crew denna resdag",
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
"day_skipper": "Skeppare denna dag",
"day_crew": "Crew denna dag",
"no_skipper": "Ingen skeppare vald",
"no_crew": "Ingen crew vald"
},
"crew": {
"title": "Profiler för skeppare och besättning",
"title": "Profiler för skeppare och crew",
"skipper_section": "Skepparens profil",
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
"crew_section": "Besättningslista",
"add_crew": "Lägg till besättningsmedlem",
"edit_crew": "Redigera besättningsmedlem",
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
"crew_section": "Crew-lista",
"add_crew": "Lägg till Crew-medlem",
"edit_crew": "Redigera Crew-medlem",
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
"name": "Namn",
"address": "adress",
"birthdate": "Födelsedag",
@@ -627,8 +664,8 @@
"save": "Spara skeppardata",
"save_member": "Spara medlem",
"saved": "Skepparens profil har sparats!",
"loading": "Besättningsfilerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
"loading": "Crew-filerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
},
"deviation": {
"title": "Tabell för kompassavvikelse",
@@ -650,7 +687,7 @@
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"delete_account_btn": "Ta bort konto oåterkalleligt",
"delete_account_confirm_title": "Radera konto?",
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
@@ -668,13 +705,13 @@
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
"deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?",
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
"invite_push_prompt_enable": "Aktivera nu",
"invite_push_prompt_later": "Senare",
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
"backup_title": "Säkerhetskopiering och återställning",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_export_title": "Skapa säkerhetskopia",
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
"backup_restore_title": "Återställ säkerhetskopian",
@@ -704,7 +741,7 @@
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
"backup_stat_entries": "{{count}} Resdagar",
"backup_stat_photos": "{{count}} Foton",
"backup_stat_crew": "{{count}} Besättningens uppgifter",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spår",
"backup_exported_at": "Exporterad: {{date}}"
},
@@ -782,7 +819,7 @@
"join_again": "Gå med igen",
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
"register_crew_account": "Skapa ett nytt konto för besättningen",
"register_crew_account": "Skapa ett nytt konto för crewen",
"username_label": "Användarens namn",
"create_passkey": "Skapa Passkey.",
"switch_language_en": "Engelska",
@@ -846,7 +883,7 @@
},
"welcome_public": {
"title": "Välkommen ombord!",
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, crew och loggboksanteckningar."
},
"nav_logs": {
"title": "Loggboksanteckningar",
@@ -868,9 +905,13 @@
"title": "Fartygsdata",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
},
"nav_crew": {
"title": "Besättningslista",
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
"profile_crew_pool": {
"title": "Stamm-Crew och skeppare",
"body": "I användarprofilen underhåller du en personpool flera skeppare (t.ex. charter) och crew för alla loggböcker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
},
"nav_stats": {
"title": "Kontrollpanel för statistik",
@@ -896,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
"ogImageAlt": "Kapteins Daagbok Logotyp"
}
+125
View File
@@ -0,0 +1,125 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
skipperIds: [],
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') {
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
} else {
legacyIds.crewIds.push(poolId)
}
}
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
activeSkipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+45 -1
View File
@@ -80,16 +80,41 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
@@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie {
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
@@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie {
logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).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',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+47 -16
View File
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
+39 -2
View File
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
title,
yacht,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -0,0 +1,75 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.js'
async function resolveLogbookKey(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 loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
+110
View File
@@ -0,0 +1,110 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { PersonData } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { syncPersonPool } from './personPoolSync.js'
export interface DecryptedPerson {
payloadId: string
data: PersonData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
const masterKey = requireMasterKey()
const records = await db.personPool.toArray()
const result: DecryptedPerson[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| PersonData
| null
if (data) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) => {
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
})
return result
}
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
const people = await loadPersonPool()
return new Map(people.map((p) => [p.payloadId, p.data]))
}
export async function savePerson(
payloadId: string,
data: PersonData,
isNew: boolean
): Promise<void> {
if (data.role === 'crew' && isNew) {
const crewCount = await db.personPool
.toArray()
.then(async (rows) => {
let count = 0
const masterKey = requireMasterKey()
for (const row of rows) {
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
if (dec?.role === 'crew') count++
}
return count
})
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
throw new Error('MAX_CREW')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'person',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export async function deletePerson(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.personPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'person',
payloadId,
data: '',
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'skipper')
}
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'crew')
}
+83
View File
@@ -0,0 +1,83 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/auth/person-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncPersonPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushPersonPool()
await pullPersonPool()
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
if (pending.length === 0) return
try {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
body: JSON.stringify({ items: pending })
})
if (!response.ok) {
console.warn('Person pool push rejected')
return
}
const { results } = await response.json()
for (let i = 0; i < results.length; i++) {
const res = results[i]
const item = pending[i]
if (!item) continue
if (res.status === 'success' && item.id !== undefined) {
await db.userSyncQueue.delete(item.id)
}
}
} catch (err) {
console.warn('Person pool push failed:', err)
}
}
async function pullPersonPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { persons } = await response.json()
if (!Array.isArray(persons)) return
const serverMap = new Map<string, (typeof persons)[0]>()
for (const p of persons) {
serverMap.set(p.payloadId, p)
const local = await db.personPool.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
await db.personPool.put({
payloadId: p.payloadId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})
}
}
const localAll = await db.personPool.toArray()
for (const local of localAll) {
if (!serverMap.has(local.payloadId)) {
const pendingCreate = await db.userSyncQueue
.where({ payloadId: local.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.personPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Person pool pull failed:', err)
}
}
+41 -4
View File
@@ -7,6 +7,7 @@ import {
reportSyncConflict,
type SyncConflict
} from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.photos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
default:
return false
}
@@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
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)
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.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)
@@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const key =
item.type === 'yacht'
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
crews,
entries,
photos,
gpsTracks
}
// 1. Sync Yacht Payload
if (yacht) {
@@ -314,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 3. Sync Crew List Payloads
// 2b. Sync Logbook Crew Selection
if (logbookCrewSelection) {
const local = await db.logbookCrewSelections.get(logbookId)
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrewSelection.encryptedData,
iv: logbookCrewSelection.iv,
tag: logbookCrewSelection.tag,
updatedAt: logbookCrewSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
for (const c of crews) {
@@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
syncAllInFlight++
recomputeSyncingState()
try {
await syncPersonPool()
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()
+61
View File
@@ -0,0 +1,61 @@
export type PersonRole = 'skipper' | 'crew'
export interface PersonData {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: PersonRole
photo?: string | null
}
export interface PersonSnapshot {
id: string
role: PersonRole
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
photo?: string | null
}
export interface LogbookCrewSelectionData {
activeSkipperId: string | null
activeCrewIds: string[]
/** Denormalized for collaborators / offline display without account pool access */
snapshotsById: Record<string, PersonSnapshot>
}
export interface EntryCrewFields {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
}
export const MAX_POOL_CREW_MEMBERS = 12
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
return {
activeSkipperId: null,
activeCrewIds: [],
snapshotsById: {}
}
}
export function emptyEntryCrewFields(): EntryCrewFields {
return {
selectedSkipperId: null,
selectedCrewIds: [],
crewSnapshotsById: {}
}
}
+62 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCurrentPosition,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
it('reports unsupported when geolocation API is missing', async () => {
vi.stubGlobal('navigator', { geolocation: undefined })
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
})
it('rejects when the browser never calls back (watchdog)', async () => {
vi.useFakeTimers()
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: () => {
// Simulate a hung desktop location service.
}
}
})
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
await vi.advanceTimersByTimeAsync(900)
await assertion
vi.useRealTimers()
})
it('resolves coordinates from getCurrentPosition', async () => {
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
} as GeolocationPosition)
}
}
})
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
})
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
permissions: {
query: vi.fn().mockResolvedValue({ state: 'denied' })
}
})
await expect(queryGeolocationPermission()).resolves.toBe('denied')
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
+72 -11
View File
@@ -1,5 +1,8 @@
const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
export interface GeoCoordinates {
lat: string
lng: string
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
speedKn: number | null
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
enableHighAccuracy?: boolean
maximumAge?: number
}
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
if (!navigator.geolocation) return 'unsupported'
if (!navigator.permissions?.query) return 'prompt'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'prompt'
}
}
function normalizeGetPositionOptions(
options: number | GetPositionOptions | undefined
): Required<GetPositionOptions> {
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
const enableHighAccuracy = opts.enableHighAccuracy ?? true
return {
timeoutMs: opts.timeoutMs ?? 15000,
enableHighAccuracy,
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
}
}
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
}
}
/**
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
* watchdog so desktop browsers without GPS cannot hang indefinitely.
*/
export function getCurrentPosition(
options?: number | GetPositionOptions
): Promise<GeoCoordinates> {
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable'))
return
}
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
window.clearTimeout(watchdog)
fn()
}
const watchdog = window.setTimeout(() => {
finish(() => reject(new Error('geolocation_timeout')))
}, timeoutMs + TIMEOUT_GRACE_MS)
navigator.geolocation.getCurrentPosition(
(pos) => {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
})
finish(() => resolve(positionFromGeolocationPosition(pos)))
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
(err) => {
finish(() => reject(err))
},
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
)
})
}
+8
View File
@@ -2,6 +2,7 @@ import {
normalizeCourseAngleString,
normalizeWindDirectionString
} from './courseAngle.js'
import type { EntryCrewFields } from '../types/person.js'
export interface LogEventPayload {
time: string
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[]
entryCrew?: EntryCrewFields
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
}
}
if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
}
return payload
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { PersonData } from '../types/person.js'
import {
legacyCrewRecordsToLogbookSelection,
pickActiveSkipperId
} from './personSnapshots.js'
function person(overrides: Partial<PersonData> & { role: PersonData['role'] }): PersonData {
return {
name: overrides.name ?? 'Test',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role: overrides.role
}
}
describe('pickActiveSkipperId', () => {
it('returns null for empty list', () => {
expect(pickActiveSkipperId([])).toBeNull()
})
it('prefers canonical skipper payload id', () => {
expect(pickActiveSkipperId(['other-skipper', 'skipper', 'third'])).toBe('skipper')
})
it('keeps first skipper when canonical id is absent', () => {
expect(pickActiveSkipperId(['alpha', 'beta'])).toBe('alpha')
})
})
describe('legacyCrewRecordsToLogbookSelection', () => {
it('does not let a later skipper overwrite the active skipper', () => {
const selection = legacyCrewRecordsToLogbookSelection([
{ payloadId: 'skipper', data: person({ role: 'skipper', name: 'Primary' }) },
{ payloadId: 'co-skipper', data: person({ role: 'skipper', name: 'Secondary' }) },
{ payloadId: 'crew-1', data: person({ role: 'crew', name: 'Crew' }) }
])
expect(selection.activeSkipperId).toBe('skipper')
expect(selection.activeCrewIds).toEqual(['crew-1'])
expect(Object.keys(selection.snapshotsById)).toEqual(['skipper', 'co-skipper', 'crew-1'])
})
it('uses first skipper when canonical id is missing', () => {
const selection = legacyCrewRecordsToLogbookSelection([
{ payloadId: 'first-skip', data: person({ role: 'skipper', name: 'First' }) },
{ payloadId: 'second-skip', data: person({ role: 'skipper', name: 'Second' }) }
])
expect(selection.activeSkipperId).toBe('first-skip')
})
})
+112
View File
@@ -0,0 +1,112 @@
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
/** Prefer canonical legacy id `skipper`, otherwise keep the first skipper encountered. */
export function pickActiveSkipperId(skipperIds: readonly string[]): string | null {
if (skipperIds.length === 0) return null
return skipperIds.find((id) => id === 'skipper') ?? skipperIds[0]
}
export function isSkipperRecord(payloadId: string, data: PersonData): boolean {
return payloadId === 'skipper' || data.role === 'skipper'
}
/** Build logbook crew selection from legacy per-logbook crew records (read-only share / migration). */
export function legacyCrewRecordsToLogbookSelection(
crews: Array<{ payloadId: string; data: PersonData }>
): LogbookCrewSelectionData {
const snapshotsById: Record<string, PersonSnapshot> = {}
const skipperIds: string[] = []
const activeCrewIds: string[] = []
for (const c of crews) {
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
if (isSkipperRecord(c.payloadId, c.data)) {
if (!skipperIds.includes(c.payloadId)) skipperIds.push(c.payloadId)
} else {
activeCrewIds.push(c.payloadId)
}
}
return {
activeSkipperId: pickActiveSkipperId(skipperIds),
activeCrewIds,
snapshotsById
}
}
export function personToSnapshot(id: string, data: PersonData): PersonSnapshot {
return {
id,
role: data.role,
name: data.name,
address: data.address,
birthDate: data.birthDate,
phone: data.phone,
nationality: data.nationality,
passportNumber: data.passportNumber,
bloodType: data.bloodType,
allergies: data.allergies,
diseases: data.diseases,
photo: data.photo ?? null
}
}
export function buildSnapshotsForSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): Record<string, PersonSnapshot> {
const snapshotsById: Record<string, PersonSnapshot> = {}
if (activeSkipperId) {
const skipper = pool.get(activeSkipperId)
if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper)
}
for (const crewId of activeCrewIds) {
const crew = pool.get(crewId)
if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew)
}
return snapshotsById
}
export function buildLogbookCrewSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): LogbookCrewSelectionData {
return {
activeSkipperId,
activeCrewIds: [...activeCrewIds],
snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool)
}
}
export function entryCrewFromLogbookSelection(
selection: LogbookCrewSelectionData
): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
export function entryCrewFromPreviousEntry(entry: Record<string, unknown>): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
const selectedSkipperId =
typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(entry.selectedCrewIds)
? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object'
? (entry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
+39
View File
@@ -0,0 +1,39 @@
/** Resize and compress an image file to a JPEG data URL (max 800×600). */
export function resizeImageFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 800
const MAX_HEIGHT = 600
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.7))
} catch (err) {
reject(err)
}
}
img.onerror = () => reject(new Error('Invalid image file'))
img.src = event.target?.result as string
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
+4 -7
View File
@@ -3,16 +3,13 @@ FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
# Copy package configurations
# Copy package configurations and Prisma schema (postinstall runs prisma generate)
COPY package*.json ./
COPY prisma ./prisma
# Install all dependencies (including devDependencies for tsc)
RUN npm ci
# Copy Prisma schema and generate Client code
COPY prisma ./prisma
RUN npx prisma generate
# Copy source and compile TypeScript
COPY src ./src
COPY tsconfig.json ./
@@ -26,8 +23,8 @@ RUN apk add --no-cache openssl libc6-compat
# Copy package configurations
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Install only production dependencies (Prisma client copied from builder; skip postinstall)
RUN npm ci --omit=dev --ignore-scripts
# Copy generated Prisma Client from builder stage
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
+4 -2
View File
@@ -5,9 +5,11 @@
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"build": "prisma generate && tsc",
"postinstall": "prisma generate",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"dev": "prisma generate && tsx watch src/index.ts",
"db:push": "prisma db push",
"test": "vitest run",
"test:watch": "vitest"
},
+26
View File
@@ -23,6 +23,7 @@ model User {
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs?
personPool PersonPayload[]
}
model PushSubscription {
@@ -86,6 +87,7 @@ model Logbook {
yachts YachtPayload[]
crews CrewPayload[]
logbookCrewSelection LogbookCrewSelectionPayload?
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
@@ -148,6 +150,30 @@ model CrewPayload {
@@unique([logbookId, payloadId])
}
model PersonPayload {
id String @id @default(uuid())
userId String
payloadId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, payloadId])
@@index([userId])
}
model LogbookCrewSelectionPayload {
id String @id @default(uuid())
logbookId String @unique
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model DeviationPayload {
id String @id @default(uuid())
logbookId String @unique
+89
View File
@@ -504,6 +504,95 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
}
})
router.get('/person-pool', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
console.warn('Person pool Prisma models missing — run prisma generate')
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
const persons = await prisma.personPayload.findMany({
where: { userId: req.userId }
})
return res.json({ persons })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
return sendInternalError(res, error, 'auth/person-pool-get')
}
})
router.post('/person-pool/push', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
const { items } = req.body
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' })
}
const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
for (const item of items) {
const { action, payloadId, data, updatedAt } = item
const itemUpdatedAt = new Date(updatedAt)
try {
if (action === 'delete') {
await prisma.personPayload.deleteMany({
where: { userId: req.userId, payloadId }
})
results.push({ payloadId, status: 'success' })
continue
}
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
const existing = await prisma.personPayload.findUnique({
where: { userId_payloadId: { userId: req.userId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.personPayload.upsert({
where: { userId_payloadId: { userId: req.userId, payloadId } },
create: {
userId: req.userId,
payloadId,
encryptedData,
iv,
tag,
updatedAt: itemUpdatedAt
},
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
results.push({ payloadId, status: 'success' })
} catch (err: any) {
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
}
}
return res.json({ results })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
return sendInternalError(res, error, 'auth/person-pool-push')
}
})
router.get('/profile', requireUser, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
+4
View File
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
@@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => {
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks
+33
View File
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
} else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
@@ -245,6 +247,29 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'logbookCrew') {
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
results.push({
payloadId,
status: 'error',
error: CREW_POOL_MIGRATION_HINT
})
continue
}
{
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.logbookCrewSelectionPayload.upsert({
where: { logbookId },
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
}
recordCollaboratorChange(
@@ -310,11 +335,19 @@ router.get('/pull', async (req: any, res) => {
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
let logbookCrewSelection = null
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
if (hasCrewPoolPrismaModels()) {
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
}
return res.json({
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '../db.js'
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
export function hasCrewPoolPrismaModels(): boolean {
const client = prisma as unknown as {
personPayload?: { findMany: unknown }
logbookCrewSelectionPayload?: { findUnique: unknown }
}
return (
typeof client.personPayload?.findMany === 'function' &&
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
)
}
export const CREW_POOL_MIGRATION_HINT =
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
export function isMissingPrismaTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}