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 { .registration-disclaimer--modal {
position: relative;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
margin: 0; margin: 0;
} }
.registration-disclaimer .auth-header {
position: relative;
}
.registration-disclaimer__close { .registration-disclaimer__close {
position: absolute; position: absolute;
top: 0; top: 12px;
right: 0; right: 12px;
z-index: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -604,6 +602,7 @@ html.scheme-dark .themed-select-option.is-selected {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 16px; padding: 16px;
overflow: hidden;
background: rgba(2, 6, 23, 0.72); background: rgba(2, 6, 23, 0.72);
} }
@@ -611,7 +610,20 @@ html.scheme-dark .themed-select-option.is-selected {
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
max-height: min(90vh, 820px); 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 { .feedback-modal {
@@ -910,6 +922,7 @@ html.scheme-dark .themed-select-option.is-selected {
.registration-disclaimer__sections { .registration-disclaimer__sections {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1800,21 +1813,51 @@ html.scheme-dark .themed-select-option.is-selected {
} }
.logbook-card-select { .logbook-card-select {
flex: 1; position: absolute;
min-width: 0; inset: 0;
display: flex; z-index: 0;
align-items: flex-start; width: 100%;
gap: 16px; height: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
border: none; border: none;
border-radius: inherit;
background: transparent; background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer; 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 { .logbook-card {
background: var(--app-surface-alt); background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop); backdrop-filter: var(--app-backdrop);
@@ -1930,6 +1973,8 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex; display: flex;
align-items: center; align-items: center;
margin-top: -2px; margin-top: -2px;
position: relative;
z-index: 2;
} }
.logbook-card-actions .btn-delete { .logbook-card-actions .btn-delete {
@@ -3327,7 +3372,14 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-accent-light, #93c5fd); color: var(--app-accent-light, #93c5fd);
} }
.logs-journal {
width: 100%;
min-width: 0;
}
.live-log-card { .live-log-card {
width: 100%;
min-width: 0;
min-height: 420px; min-height: 420px;
} }
@@ -3337,6 +3389,31 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted); 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 { .live-log-layout {
display: grid; display: grid;
grid-template-columns: minmax(148px, 200px) 1fr; 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 { body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
pointer-events: none; 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 UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx' import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.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) // Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx' // import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx' import LogEntriesList from './components/LogEntriesList.tsx'
@@ -161,6 +163,7 @@ function App() {
const userId = localStorage.getItem('active_userid') const userId = localStorage.getItem('active_userid')
if (!userId) return if (!userId) return
void syncAppearancePrefs(userId) void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => { useEffect(() => {
@@ -701,7 +704,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')} onClick={() => void handleTabChange('crew')}
data-tour="nav-crew" data-tour="nav-logbook-crew"
> >
<Users size={18} /> <Users size={18} />
{t('nav.crew')} {t('nav.crew')}
@@ -752,10 +755,10 @@ function App() {
)} )}
{activeTab === 'crew' && ( {activeTab === 'crew' && (
<CrewForm <LogbookCrewPicker
logbookId={activeLogbookId} logbookId={activeLogbookId}
readOnly={logbookReadOnly} readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner} selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/> />
)} )}
@@ -800,7 +803,7 @@ function App() {
type="button" type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`} className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')} onClick={() => void handleTabChange('crew')}
data-tour="nav-crew" data-tour="nav-logbook-crew"
> >
<Users size={20} /> <Users size={20} />
<span>{t('nav.crew')}</span> <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 { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react' 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" /> <Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2> <h2>{t('crew.crew_section')}</h2>
</div> </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' }}> <button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} /> <Plus size={16} />
{t('crew.add_crew')} {t('crew.add_crew')}
+23 -4
View File
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx' 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 LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react' import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js' import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n) 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 ( return (
<div className="app-layout"> <div className="app-layout">
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button <button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')} onClick={() => setActiveTab('crew')}
data-tour="nav-crew" data-tour="nav-logbook-crew"
> >
<Users size={18} /> <Users size={18} />
{t('nav.crew')} {t('nav.crew')}
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)} )}
{activeTab === 'crew' && ( {activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} /> <LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)} )}
</main> </main>
</div> </div>
+6 -1
View File
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
if (event.key === 'Escape') onClose() if (event.key === 'Escape') onClose()
} }
window.addEventListener('keydown', onKeyDown) 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]) }, [open, onClose])
if (!open) return null 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' } from '../utils/liveEventCodes.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.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 { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { import {
dedupeSailNames, dedupeSailNames,
@@ -167,11 +171,13 @@ export default function LiveLogView({
const undoPhotoIdRef = useRef<string | null>(null) const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null) const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false) const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy)
const initSeqRef = useRef(0) const initSeqRef = useRef(0)
const eventsRef = useRef(events) const eventsRef = useRef(events)
const dateRef = useRef(date) const dateRef = useRef(date)
eventsRef.current = events eventsRef.current = events
dateRef.current = date dateRef.current = date
busyRef.current = busy
const defaultSails = useMemo( const defaultSails = useMemo(
() => (i18n.language === 'de' () => (i18n.language === 'de'
@@ -185,6 +191,10 @@ export default function LiveLogView({
) )
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') 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 applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -276,7 +286,9 @@ export default function LiveLogView({
return () => { return () => {
initSeqRef.current += 1 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(() => { useEffect(() => {
if (!loading && entryId) { if (!loading && entryId) {
@@ -297,15 +309,34 @@ export default function LiveLogView({
useEffect(() => { useEffect(() => {
if (!entryId || loading) return if (!entryId || loading) return
let cancelled = false
let startTimer: number | undefined
let intervalRef: number | undefined
const maybeAutoPosition = async () => { 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) const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true autoPositionBusyRef.current = true
try { 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, { await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat, gpsLat: coords.lat,
gpsLng: coords.lng, gpsLng: coords.lng,
@@ -313,23 +344,26 @@ export default function LiveLogView({
}) })
await refreshEntry(entryId) await refreshEntry(entryId)
} catch { } catch {
// Silent — auto-position is best-effort // Best-effort; hint banner shows when no position fix exists yet.
} finally { } finally {
autoPositionBusyRef.current = false autoPositionBusyRef.current = false
} }
} }
let intervalRef: number | undefined void queryGeolocationPermission().then((permission) => {
const startTimer = window.setTimeout(() => { if (cancelled || permission !== 'granted') return
void maybeAutoPosition() startTimer = window.setTimeout(() => {
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS) void maybeAutoPosition()
}, AUTO_POSITION_START_DELAY_MS) intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
})
return () => { return () => {
window.clearTimeout(startTimer) cancelled = true
if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef) if (intervalRef !== undefined) window.clearInterval(intervalRef)
} }
}, [entryId, loading, logbookId, refreshEntry, busy]) }, [entryId, loading, logbookId, refreshEntry])
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<boolean | void>, action: () => Promise<boolean | void>,
@@ -364,8 +398,15 @@ export default function LiveLogView({
const openSogModal = async () => { const openSogModal = async () => {
let prefill = '' let prefill = ''
try { try {
const pos = await getCurrentPosition() const permission = await queryGeolocationPermission()
if (pos.speedKn != null) prefill = String(pos.speedKn) if (permission === 'granted') {
const pos = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
})
if (pos.speedKn != null) prefill = String(pos.speedKn)
}
} catch { } catch {
// Manual entry when GPS speed unavailable // Manual entry when GPS speed unavailable
} }
@@ -405,7 +446,16 @@ export default function LiveLogView({
setFixGpsLoading(true) setFixGpsLoading(true)
setModal('fix') setModal('fix')
try { 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) setFixLat(coords.lat)
setFixLng(coords.lng) setFixLng(coords.lng)
} catch { } catch {
@@ -419,12 +469,28 @@ export default function LiveLogView({
setFixGpsLoading(true) setFixGpsLoading(true)
setFixGpsUnavailable(false) setFixGpsUnavailable(false)
try { 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) setFixLat(coords.lat)
setFixLng(coords.lng) setFixLng(coords.lng)
} catch { } catch {
setFixGpsUnavailable(true) 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 { } finally {
setFixGpsLoading(false) setFixGpsLoading(false)
} }
@@ -757,7 +823,7 @@ export default function LiveLogView({
return ( return (
<> <>
<div className="form-card live-log-card"> <div className="live-log-card">
<div className="section-title-bar mb-4"> <div className="section-title-bar mb-4">
<div className="form-header" style={{ margin: 0 }}> <div className="form-header" style={{ margin: 0 }}>
<Radio size={24} className="form-icon" /> <Radio size={24} className="form-icon" />
@@ -786,6 +852,13 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>} {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"> <div className="live-log-layout">
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}> <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}> <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()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3> <h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && ( {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}> <fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend> <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 { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -239,6 +240,12 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') 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 localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = [] const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
@@ -277,6 +284,12 @@ export default function LogEntriesList({
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) 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 = { const initialPayload = {
date: todayStr, date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries), dayOfTravel: getNextTravelDayNumber(decryptedEntries),
@@ -285,6 +298,9 @@ export default function LogEntriesList({
freshwater, freshwater,
fuel, fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}), ...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '', signSkipper: '',
signCrew: '', signCrew: '',
events: [] events: []
@@ -381,7 +397,10 @@ export default function LogEntriesList({
setReturnToLiveAfterEditor(true) setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId) setSelectedEntryId(entryId)
}} }}
onSwitchToList={() => setViewMode('list')} onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/> />
) )
} }
@@ -401,7 +420,7 @@ export default function LogEntriesList({
: entries[0]?.id ?? null : entries[0]?.id ?? null
return ( return (
<div className="form-card"> <div className="logs-journal">
<div className="section-title-bar mb-6"> <div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}> <div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" /> <Calendar size={24} className="form-icon" />
@@ -466,8 +485,14 @@ export default function LogEntriesList({
type="button" type="button"
className="logbook-card-select" className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)} onClick={() => setSelectedEntryId(item.id)}
> aria-label={
<div className="card-icon"> 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} /> <FileText size={24} />
</div> </div>
@@ -488,8 +513,7 @@ export default function LogEntriesList({
</div> </div>
</div> </div>
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden /> <ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
</button>
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}> <button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} /> <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 { 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 PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.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 TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { import {
@@ -108,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw)) ? parseFloat(String(motorHoursRaw))
: undefined, : undefined,
events: (decrypted.events as LogEventPayload[]) || [] events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
}) })
return JSON.stringify({ return JSON.stringify({
@@ -168,6 +172,8 @@ export default function LogEntryEditor({
const [greywaterLevel, setGreywaterLevel] = useState('0') const [greywaterLevel, setGreywaterLevel] = useState('0')
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({}) const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
// Signatures // Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('') const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('') const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
@@ -279,7 +285,8 @@ export default function LogEntryEditor({
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined, motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events events: eventsOverride ?? events,
entryCrew
}) })
}, [ }, [
date, dayOfTravel, departure, destination, date, dayOfTravel, departure, destination,
@@ -287,7 +294,8 @@ export default function LogEntryEditor({
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel, greywaterLevel,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events events,
entryCrew
]) ])
useEffect(() => { useEffect(() => {
@@ -706,6 +714,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent))) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
@@ -744,6 +753,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent))) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted)) setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
@@ -2032,6 +2042,13 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} /> <PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
<EntryCrewSection
logbookId={logbookId}
readOnly={readOnly}
value={entryCrew}
onChange={setEntryCrew}
/>
<SignatureSection <SignatureSection
readOnly={readOnly} readOnly={readOnly}
disabled={saving} 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 ( return (
<div <div
key={lb.id} 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 {!isEditingTitle && (
type="button" <button
className="logbook-card-select" type="button"
onClick={() => onSelectLogbook(lb.id, lb.title)} className="logbook-card-select"
> onClick={() => onSelectLogbook(lb.id, lb.title)}
<div className="card-icon"> aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} /> <BookOpen size={24} />
</div> </div>
@@ -246,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text" className="logbook-title-inline-edit input-text"
value={editingTitleDraft} value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)} onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
@@ -287,7 +290,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
</span> </span>
</div> </div>
</div> </div>
</button>
{!lb.isShared && ( {!lb.isShared && (
<div className="logbook-card-actions"> <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 { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx' 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 LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react' import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states // Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook') const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null) 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 [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([]) const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([]) const [gpsTracks, setGpsTracks] = useState<any[]>([])
@@ -71,18 +78,42 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
setYacht(decYacht) setYacht(decYacht)
// Decrypt Crews if (data.logbookCrewSelection) {
const decCrews = [] const decSel = await decryptJson(
if (data.crews) { data.logbookCrewSelection.encryptedData,
for (const c of data.crews) { data.logbookCrewSelection.iv,
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer) data.logbookCrewSelection.tag,
decCrews.push({ keyBuffer
payloadId: c.payloadId, )
data: dec 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 // Decrypt Entries
const decEntries = [] const decEntries = []
@@ -234,10 +265,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)} )}
{activeTab === 'crew' && ( {activeTab === 'crew' && (
<CrewForm <LogbookCrewPicker
logbookId="shared" logbookId="shared"
readOnly={true} readOnly={true}
preloadedData={crews} selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/> />
)} )}
</main> </main>
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`} className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document" 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"> <div className="auth-header">
<ScrollText className="auth-icon accent" size={48} /> <ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2> <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> </div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p> <p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
@@ -30,6 +30,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx' import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx' import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { import {
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} /> <UserProfilePreferences userId={profile.userId} />
</div> </div>
<PersonPoolForm />
<section className="member-editor-card glass"> <section className="member-editor-card glass">
<div className="profile-section-header"> <div className="profile-section-header">
<Shield size={20} /> <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(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1) expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 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', () => { it('excludes profile, stats and feedback from demo tour', () => {
+19 -6
View File
@@ -26,7 +26,8 @@ export type TourStepId =
| 'entry_open' | 'entry_open'
| 'entry_track' | 'entry_track'
| 'nav_vessel' | 'nav_vessel'
| 'nav_crew' | 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats' | 'nav_stats'
| 'nav_feedback' | 'nav_feedback'
| 'nav_profile' | 'nav_profile'
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open', 'entry_open',
'entry_track', 'entry_track',
'nav_vessel', 'nav_vessel',
'nav_crew', 'profile_crew_pool',
'nav_logbook_crew',
'nav_stats', 'nav_stats',
'nav_feedback', 'nav_feedback',
'nav_profile', 'nav_profile',
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
/** Public demo has no stats/feedback/profile UI — skip those steps. */ /** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [ export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats', 'nav_stats',
'nav_feedback', 'nav_feedback',
'nav_profile', 'nav_profile',
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'entry_open', 'entry_open',
'entry_track', 'entry_track',
'nav_vessel', 'nav_vessel',
'nav_crew', 'nav_logbook_crew',
'nav_stats', 'nav_stats',
'nav_feedback' 'nav_feedback'
]) ])
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]', entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]', entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]', 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_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]', nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]', nav_profile: '[data-tour="nav-profile"]',
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number { export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400 if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180 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 return 0
} }
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('vessel') nav.setActiveTab('vessel')
} }
if (stepId === 'nav_crew') { if (stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null) 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') nav.setActiveTab('crew')
} }
if (stepId === 'nav_stats') { if (stepId === 'nav_stats') {
+75 -34
View File
@@ -33,7 +33,7 @@
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"vessel": "Skibsdata", "vessel": "Skibsdata",
"crew": "Besætningsliste", "crew": "Crew",
"deviation": "Tabel over distraktioner", "deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen", "logs": "Indlæg i logbogen",
"stats": "Statistik", "stats": "Statistik",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skipper har udgivet", "sign_badge_skipper_title_valid": "Skipper har udgivet",
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret", "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_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_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_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 besætningens underskrifter.", "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_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_proceed": "Tegn",
"sign_cancel": "Annuller", "sign_cancel": "Annuller",
"sign_cleared_re_sign_title": "Underskrifter fjernet", "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!", "no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
"back_to_list": "Tilbage til tidsskriftslisten", "back_to_list": "Tilbage til tidsskriftslisten",
"save": "Gem logbogsside", "save": "Gem logbogsside",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Indtast tekst…", "live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast", "live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.", "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_event_generic": "Hændelse",
"live_weather_btn": "Vejr", "live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr", "live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
@@ -386,12 +387,12 @@
"track_map_error": "Kortet kunne ikke indlæses.", "track_map_error": "Kortet kunne ikke indlæses.",
"exporting": "Eksport...", "exporting": "Eksport...",
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.", "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_copied": "Invitationslink kopieret til udklipsholderen!",
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.", "invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / besætning", "collaborators_list": "Medlemmer / crew",
"revoke": "Fjerne", "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_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer", "invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log", "nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Slet logbog", "delete_btn": "Slet logbog",
"section_owned": "Mine logbøger", "section_owned": "Mine logbøger",
"section_shared": "Fælles 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": "Egen logbog",
"role_owner_hint": "Du er ejer og skipper af denne logbog", "role_owner_hint": "Du er ejer og skipper af denne logbog",
"role_crew": "Adgang for besætning", "role_crew": "Adgang for crew",
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den", "role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
"role_read": "Læs kun", "role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering", "role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}", "open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog", "edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen", "edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes", "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_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
"tour_restart": "Start turen igen", "tour_restart": "Start turen igen",
"push_title": "Push-meddelelser", "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_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 besætningen", "push_enable": "Giv os besked om ændringer i crewen",
"push_active": "Push-meddelelser er aktive på denne enhed.", "push_active": "Push-meddelelser er aktive på denne enhed.",
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.", "push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.", "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_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." "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": { "crew": {
"title": "Skipper- og besætningsprofiler", "title": "Skipper- og Crew-profiler",
"skipper_section": "Skipper-profil", "skipper_section": "Skipper-profil",
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.", "skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
"crew_section": "Besætningsliste", "crew_section": "Crew-liste",
"add_crew": "Tilføj besætningsmedlem", "add_crew": "Tilføj Crew-medlem",
"edit_crew": "Rediger besætningsmedlem", "edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.", "no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.", "max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
"name": "Navn", "name": "Navn",
"address": "adresse", "address": "adresse",
"birthdate": "Fødselsdag", "birthdate": "Fødselsdag",
@@ -627,8 +664,8 @@
"save": "Gem skipper-data", "save": "Gem skipper-data",
"save_member": "Gem medlem", "save_member": "Gem medlem",
"saved": "Skipperprofilen er blevet gemt!", "saved": "Skipperprofilen er blevet gemt!",
"loading": "Besætningsfilerne er indlæst.", "loading": "Crew-filerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?" "delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
}, },
"deviation": { "deviation": {
"title": "Tabel over kompasafvigelser", "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}}.", "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.", "gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)", "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_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_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!", "share_copied": "Link kopieret!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Scan QR-koden med din telefon", "link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket", "link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone", "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_btn": "Slet konto uigenkaldeligt",
"delete_account_confirm_title": "Slette konto?", "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?", "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.", "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...", "deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?", "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_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 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_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_enable": "Aktiver nu",
"invite_push_prompt_later": "Senere", "invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.", "invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
"backup_title": "Sikkerhedskopiering og gendannelse", "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_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.", "backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi", "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_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
"backup_stat_entries": "{{count}} Rejsedage", "backup_stat_entries": "{{count}} Rejsedage",
"backup_stat_photos": "{{count}} Fotos", "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_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksporteret: {{date}}" "backup_exported_at": "Eksporteret: {{date}}"
}, },
@@ -846,7 +883,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Velkommen om bord!", "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": { "nav_logs": {
"title": "Indlæg i logbogen", "title": "Indlæg i logbogen",
@@ -868,9 +905,13 @@
"title": "Skibsdata", "title": "Skibsdata",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage." "body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
}, },
"nav_crew": { "profile_crew_pool": {
"title": "Besætningsliste", "title": "Stamm-Crew og skippere",
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere." "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": { "nav_stats": {
"title": "Statistik-dashboard", "title": "Statistik-dashboard",
@@ -896,7 +937,7 @@
}, },
"seo": { "seo": {
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)", "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", "keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
"ogImageAlt": "Kapteins Daagbok Logo" "ogImageAlt": "Kapteins Daagbok Logo"
} }
+47 -6
View File
@@ -33,7 +33,7 @@
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"vessel": "Schiffsdaten", "vessel": "Schiffsdaten",
"crew": "Crew-Liste", "crew": "Crew",
"deviation": "Ablenkungstabelle", "deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge", "logs": "Logbucheinträge",
"stats": "Statistik", "stats": "Statistik",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen", "live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "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_event_generic": "Ereignis",
"live_weather_btn": "Wetter", "live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen", "live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
@@ -468,6 +469,7 @@
"role_read": "Nur Lesen", "role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung", "role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen", "open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen", "edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs", "edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt", "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_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." "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": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil", "skipper_section": "Skipper-Profil",
@@ -614,7 +651,7 @@
"add_crew": "Crew-Mitglied hinzufügen", "add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten", "edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.", "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", "name": "Name",
"address": "Anschrift", "address": "Anschrift",
"birthdate": "Geburtstag", "birthdate": "Geburtstag",
@@ -846,7 +883,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Willkommen an Bord!", "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": { "nav_logs": {
"title": "Logbucheinträge", "title": "Logbucheinträge",
@@ -868,9 +905,13 @@
"title": "Schiffsdaten", "title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar." "body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
}, },
"nav_crew": { "profile_crew_pool": {
"title": "Crew-Liste", "title": "Stammcrew & Skipper",
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu." "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": { "nav_stats": {
"title": "Statistik-Dashboard", "title": "Statistik-Dashboard",
+46 -5
View File
@@ -33,7 +33,7 @@
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"vessel": "Vessel Profile", "vessel": "Vessel Profile",
"crew": "Crew List", "crew": "Crew",
"deviation": "Deviation Table", "deviation": "Deviation Table",
"logs": "Logbook Entries", "logs": "Logbook Entries",
"stats": "Statistics", "stats": "Statistics",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry", "live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.", "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_event_generic": "Event",
"live_weather_btn": "Weather", "live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather", "live_weather_owm_btn": "Fetch OpenWeatherMap weather",
@@ -468,6 +469,7 @@
"role_read": "Read only", "role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing", "role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}", "open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook", "edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook", "edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully", "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_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." "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": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile", "skipper_section": "Skipper Profile",
@@ -614,7 +651,7 @@
"add_crew": "Add Crew Member", "add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member", "edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.", "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", "name": "Full Name",
"address": "Address", "address": "Address",
"birthdate": "Date of Birth", "birthdate": "Date of Birth",
@@ -868,9 +905,13 @@
"title": "Vessel data", "title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day." "body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
}, },
"nav_crew": { "profile_crew_pool": {
"title": "Crew list", "title": "Core Crew & skippers",
"body": "Manage crew members and assign them to travel days later." "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": { "nav_stats": {
"title": "Statistics dashboard", "title": "Statistics dashboard",
+76 -35
View File
@@ -33,7 +33,7 @@
"nav": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
"vessel": "Skipsdata", "vessel": "Skipsdata",
"crew": "Mannskapsliste", "crew": "Crew",
"deviation": "Tabell over distraksjoner", "deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer", "logs": "Loggbokoppføringer",
"stats": "Statistikk", "stats": "Statistikk",
@@ -183,7 +183,7 @@
"consumption": "Daglig forbruk", "consumption": "Daglig forbruk",
"signatures": "Underskrifter / frigivelse", "signatures": "Underskrifter / frigivelse",
"sign_skipper": "Skippers signatur", "sign_skipper": "Skippers signatur",
"sign_crew": "Mannskapets signatur", "sign_crew": "Crews signatur",
"sign_hint": "Signer med finger, penn eller mus", "sign_hint": "Signer med finger, penn eller mus",
"sign_clear": "Slett", "sign_clear": "Slett",
"sign_export_image": "[Signatur]", "sign_export_image": "[Signatur]",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skipper har gitt ut", "sign_badge_skipper_title_valid": "Skipper har gitt ut",
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret", "sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor", "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_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_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 mannskapets signaturer.", "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_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_proceed": "Skilt",
"sign_cancel": "Avbryt", "sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Signaturer fjernet", "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!", "no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
"back_to_list": "Tilbake til tidsskriftlisten", "back_to_list": "Tilbake til tidsskriftlisten",
"save": "Lagre loggbokside", "save": "Lagre loggbokside",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Skriv inn tekst…", "live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør", "live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.", "live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse", "live_event_generic": "Hendelse",
"live_weather_btn": "Vær", "live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær", "live_weather_owm_btn": "Hent OpenWeatherMap-vær",
@@ -386,12 +387,12 @@
"track_map_error": "Kartet kunne ikke lastes inn.", "track_map_error": "Kartet kunne ikke lastes inn.",
"exporting": "Eksport...", "exporting": "Eksport...",
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.", "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_copied": "Invitasjonslenke kopiert til utklippstavlen!",
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.", "invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Besetning", "collaborators_list": "Medlemmer / Crew",
"revoke": "Fjern", "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_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer", "invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log", "nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Slett loggbok", "delete_btn": "Slett loggbok",
"section_owned": "Loggbøkene mine", "section_owned": "Loggbøkene mine",
"section_shared": "Felles loggbøker", "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": "Egen loggbok",
"role_owner_hint": "Du er eier og skipper av denne loggboken", "role_owner_hint": "Du er eier og skipper av denne loggboken",
"role_crew": "Tilgang for mannskapet", "role_crew": "Tilgang for crewet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den", "role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
"role_read": "Bare les", "role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering", "role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}", "open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok", "edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken", "edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn", "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_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
"tour_restart": "Start turen på nytt", "tour_restart": "Start turen på nytt",
"push_title": "Push-varsler", "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_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 mannskapet", "push_enable": "Gi oss beskjed om endringer i crewet",
"push_active": "Push-varsler er aktive på denne enheten.", "push_active": "Push-varsler er aktive på denne enheten.",
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.", "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_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_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres." "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": { "crew": {
"title": "Skipper- og mannskapsprofiler", "title": "Skipper- og Crew-profiler",
"skipper_section": "Skipperprofil", "skipper_section": "Skipperprofil",
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.", "skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
"crew_section": "Mannskapsliste", "crew_section": "Crew-liste",
"add_crew": "Legg til besetningsmedlem", "add_crew": "Legg til Crew-medlem",
"edit_crew": "Rediger besetningsmedlem", "edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.", "no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.", "max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
"name": "Navn", "name": "Navn",
"address": "adresse", "address": "adresse",
"birthdate": "Bursdag", "birthdate": "Bursdag",
@@ -627,8 +664,8 @@
"save": "Lagre skipperdata", "save": "Lagre skipperdata",
"save_member": "Lagre medlem", "save_member": "Lagre medlem",
"saved": "Skipperprofilen er vellykket lagret!", "saved": "Skipperprofilen er vellykket lagret!",
"loading": "Mannskapsfilene er lastet inn...", "loading": "Crew-filene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?" "delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
}, },
"deviation": { "deviation": {
"title": "Tabell over kompassavvik", "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}}.", "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.", "gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)", "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_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_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!", "share_copied": "Linken er kopiert!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Skann QR-koden med telefonen", "link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken", "link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone", "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_btn": "Slett konto ugjenkallelig",
"delete_account_confirm_title": "Slett konto?", "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?", "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.", "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...", "deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?", "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_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 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_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_enable": "Aktiver nå",
"invite_push_prompt_later": "Senere", "invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.", "invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
"backup_title": "Sikkerhetskopiering og gjenoppretting", "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_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.", "backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi", "backup_restore_title": "Gjenopprett sikkerhetskopi",
@@ -704,7 +741,7 @@
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?", "backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
"backup_stat_entries": "{{count}} Reisedager", "backup_stat_entries": "{{count}} Reisedager",
"backup_stat_photos": "{{count}} Bilder", "backup_stat_photos": "{{count}} Bilder",
"backup_stat_crew": "{{count}} Mannskapsposter", "backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor", "backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksportert: {{date}}" "backup_exported_at": "Eksportert: {{date}}"
}, },
@@ -846,7 +883,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Velkommen om bord!", "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": { "nav_logs": {
"title": "Loggbokoppføringer", "title": "Loggbokoppføringer",
@@ -868,9 +905,13 @@
"title": "Skipsdata", "title": "Skipsdata",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager." "body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
}, },
"nav_crew": { "profile_crew_pool": {
"title": "Mannskapsliste", "title": "Stamm-Crew og skippere",
"body": "Administrer mannskapet og tilordne dem til reisedager senere." "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": { "nav_stats": {
"title": "Dashbord for statistikk", "title": "Dashbord for statistikk",
@@ -896,7 +937,7 @@
}, },
"seo": { "seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)", "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", "keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
"ogImageAlt": "Kapteins Daagbok Logo" "ogImageAlt": "Kapteins Daagbok Logo"
} }
+77 -36
View File
@@ -33,7 +33,7 @@
"nav": { "nav": {
"dashboard": "Instrumentpanel", "dashboard": "Instrumentpanel",
"vessel": "Fartygsdata", "vessel": "Fartygsdata",
"crew": "Besättningslista", "crew": "Crew",
"deviation": "Distraktionsbord", "deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar", "logs": "Loggboksanteckningar",
"stats": "Statistik", "stats": "Statistik",
@@ -183,7 +183,7 @@
"consumption": "Daglig konsumtion", "consumption": "Daglig konsumtion",
"signatures": "Underskrifter / frisläppande", "signatures": "Underskrifter / frisläppande",
"sign_skipper": "Skepparens signatur", "sign_skipper": "Skepparens signatur",
"sign_crew": "Besättningens signatur", "sign_crew": "Crews signatur",
"sign_hint": "Signera med finger, penna eller mus", "sign_hint": "Signera med finger, penna eller mus",
"sign_clear": "Radera", "sign_clear": "Radera",
"sign_export_image": "[Signatur]", "sign_export_image": "[Signatur]",
@@ -203,16 +203,16 @@
"sign_badge_skipper_title_valid": "Skepparen har släppt", "sign_badge_skipper_title_valid": "Skepparen har släppt",
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats", "sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan", "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_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_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 besättningens signaturer.", "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_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_proceed": "Teckna",
"sign_cancel": "Avbryt", "sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Underskrifter borttagna", "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!", "no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
"back_to_list": "Tillbaka till tidskriftslistan", "back_to_list": "Tillbaka till tidskriftslistan",
"save": "Spara loggbokssida", "save": "Spara loggbokssida",
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Ange text…", "live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga", "live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.", "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_event_generic": "Händelse",
"live_weather_btn": "Väder", "live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder", "live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
@@ -386,12 +387,12 @@
"track_map_error": "Kartan kunde inte läsas in.", "track_map_error": "Kartan kunde inte läsas in.",
"exporting": "Export...", "exporting": "Export...",
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.", "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_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.", "invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Besättning", "collaborators_list": "Medlemmar / Crew",
"revoke": "Ta bort", "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_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar", "invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log", "nmea_import_title": "Import NMEA log",
@@ -460,14 +461,15 @@
"delete_btn": "Radera loggbok", "delete_btn": "Radera loggbok",
"section_owned": "Mina loggböcker", "section_owned": "Mina loggböcker",
"section_shared": "Delade 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": "Egen loggbok",
"role_owner_hint": "Du är ägare och skeppare till denna loggbok", "role_owner_hint": "Du är ägare och skeppare till denna loggbok",
"role_crew": "Tillträde för besättningen", "role_crew": "Tillträde för crewen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den", "role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
"role_read": "Endast läsning", "role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering", "role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}", "open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok", "edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken", "edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn", "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_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
"tour_restart": "Starta resan igen", "tour_restart": "Starta resan igen",
"push_title": "Push-meddelanden", "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_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 besättningen", "push_enable": "Meddela oss om förändringar i crewen",
"push_active": "Push-meddelanden är aktiva på den här enheten.", "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_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_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_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." "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": { "crew": {
"title": "Profiler för skeppare och besättning", "title": "Profiler för skeppare och crew",
"skipper_section": "Skepparens profil", "skipper_section": "Skepparens profil",
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.", "skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
"crew_section": "Besättningslista", "crew_section": "Crew-lista",
"add_crew": "Lägg till besättningsmedlem", "add_crew": "Lägg till Crew-medlem",
"edit_crew": "Redigera besättningsmedlem", "edit_crew": "Redigera Crew-medlem",
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.", "no_crew": "Inga Crew-medlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.", "max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
"name": "Namn", "name": "Namn",
"address": "adress", "address": "adress",
"birthdate": "Födelsedag", "birthdate": "Födelsedag",
@@ -627,8 +664,8 @@
"save": "Spara skeppardata", "save": "Spara skeppardata",
"save_member": "Spara medlem", "save_member": "Spara medlem",
"saved": "Skepparens profil har sparats!", "saved": "Skepparens profil har sparats!",
"loading": "Besättningsfilerna är laddade...", "loading": "Crew-filerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?" "delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
}, },
"deviation": { "deviation": {
"title": "Tabell för kompassavvikelse", "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}}.", "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.", "gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)", "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_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_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!", "share_copied": "Länk kopierad!",
@@ -658,7 +695,7 @@
"link_qr_hint": "Skanna QR-koden med mobilen", "link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken", "link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon", "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_btn": "Ta bort konto oåterkalleligt",
"delete_account_confirm_title": "Radera konto?", "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?", "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.", "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...", "deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?", "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_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 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_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_enable": "Aktivera nu",
"invite_push_prompt_later": "Senare", "invite_push_prompt_later": "Senare",
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.", "invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
"backup_title": "Säkerhetskopiering och återställning", "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_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_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", "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_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
"backup_stat_entries": "{{count}} Resdagar", "backup_stat_entries": "{{count}} Resdagar",
"backup_stat_photos": "{{count}} Foton", "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_stat_tracks": "{{count}} GPS-spår",
"backup_exported_at": "Exporterad: {{date}}" "backup_exported_at": "Exporterad: {{date}}"
}, },
@@ -782,7 +819,7 @@
"join_again": "Gå med igen", "join_again": "Gå med igen",
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.", "login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
"or_sign_up": "ELLER REGISTRERA DIG IGEN", "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", "username_label": "Användarens namn",
"create_passkey": "Skapa Passkey.", "create_passkey": "Skapa Passkey.",
"switch_language_en": "Engelska", "switch_language_en": "Engelska",
@@ -846,7 +883,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Välkommen ombord!", "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": { "nav_logs": {
"title": "Loggboksanteckningar", "title": "Loggboksanteckningar",
@@ -868,9 +905,13 @@
"title": "Fartygsdata", "title": "Fartygsdata",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar." "body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
}, },
"nav_crew": { "profile_crew_pool": {
"title": "Besättningslista", "title": "Stamm-Crew och skeppare",
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare." "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": { "nav_stats": {
"title": "Kontrollpanel för statistik", "title": "Kontrollpanel för statistik",
@@ -896,7 +937,7 @@
}, },
"seo": { "seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)", "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", "keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
"ogImageAlt": "Kapteins Daagbok Logotyp" "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 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 { export interface SyncQueueItem {
id?: number id?: number
action: 'create' | 'update' | 'delete' 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 payloadId: string // payloadId or logbookId depending on the type
logbookId: string logbookId: string
data: string // JSON representation of the local record data: string // JSON representation of the local record
updatedAt: string updatedAt: string
} }
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord { export interface EntryDraftRecord {
logbookId: string logbookId: string
entryId: string entryId: string
@@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie {
gpsTracks!: Table<LocalGpsTrack> gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive> nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey> logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
syncQueue!: Table<SyncQueueItem> syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]> entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() { constructor() {
@@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie {
logbookKeys: 'logbookId', logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt' 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 { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import { import {
buildDemoCrewRecords, buildDemoPersonPool,
buildDemoEntryPayloads, buildDemoEntryPayloads,
buildDemoYachtData buildDemoYachtData
} from './demoLogbookData.js' } from './demoLogbookData.js'
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord( async function putEncryptedRecord(
logbookId: string, logbookId: string,
key: ArrayBuffer, key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack', type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string, payloadId: string,
data: unknown, data: unknown,
now: string now: string
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
tag: encrypted.tag, tag: encrypted.tag,
updatedAt: now 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') { } else if (type === 'yacht') {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
tag: encrypted.tag, tag: encrypted.tag,
updatedAt: now 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({ await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create', action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type, type,
payloadId: type === 'yacht' ? logbookId : payloadId, payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId, logbookId,
data: JSON.stringify(encrypted), data: JSON.stringify(encrypted),
updatedAt: now 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> { 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() const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now) await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) { const poolMap = await seedPersonPool(masterKey, now)
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, 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 { export interface DemoSeedResult {
+39 -2
View File
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003' 'a0000001-0000-4000-8000-000000000003'
] as const ] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010' const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec { export interface DemoDaySpec {
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
export interface PublicDemoFixture { export interface PublicDemoFixture {
title: string title: string
yacht: Record<string, unknown> yacht: Record<string, unknown>
/** @deprecated legacy share payload */
crews: DemoCrewRecord[] crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }> entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }> gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[] photos: never[]
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
} }
} }
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] { export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = isGermanLocale(i18n.language) const isDe = isGermanLocale(i18n.language)
return [ return [
{ {
payloadId: 'skipper', payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: { data: {
name: 'Demo Skipper', name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel', 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 { export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title') const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData() const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords() const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays() const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = [] const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = [] const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination, destination: day.destination,
freshwater: { ...day.freshwater }, freshwater: { ...day.freshwater },
fuel: { ...day.fuel }, fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '', signSkipper: '',
signCrew: '', signCrew: '',
events: day.events events: day.events
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
title, title,
yacht, yacht,
crews, crews,
personPool,
logbookCrewSelection,
entries, entries,
gpsTracks, gpsTracks,
photos: [], photos: [],
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown> entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string } trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> { }> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays() const days = buildDemoDays()
return days.map((day) => { return days.map((day) => {
const entryId = crypto.randomUUID() const entryId = crypto.randomUUID()
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination, destination: day.destination,
freshwater: { ...day.freshwater }, freshwater: { ...day.freshwater },
fuel: { ...day.fuel }, fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '', signSkipper: '',
signCrew: '', signCrew: '',
events: day.events 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, reportSyncConflict,
type SyncConflict type SyncConflict
} from './syncConflicts.js' } from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.photos.get(item.payloadId)) return !!(await db.photos.get(item.payloadId))
case 'gpsTrack': case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId)) return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
default: default:
return false return false
} }
@@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
type PulledServerPayload = { type PulledServerPayload = {
yacht?: { updatedAt: string } | null yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }> crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }> entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }> photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
const serverTimes = new Map<string, string>() const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt) if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.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 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 e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt) for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
@@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
continue 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) const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) { if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id) if (item.id !== undefined) staleIds.push(item.id)
@@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false return false
} }
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json() const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks } await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
crews,
entries,
photos,
gpsTracks
}
// 1. Sync Yacht Payload // 1. Sync Yacht Payload
if (yacht) { 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>() const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) { if (crews && Array.isArray(crews)) {
for (const c of crews) { for (const c of crews) {
@@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
syncAllInFlight++ syncAllInFlight++
recomputeSyncingState() recomputeSyncingState()
try { try {
await syncPersonPool()
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index) // 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray() 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 { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js' import {
getCurrentPosition,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => { describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => { it('parses coordinates with comma decimals', () => {
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull() expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).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 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 { export interface GeoCoordinates {
lat: string lat: string
lng: string lng: string
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
speedKn: number | null 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 { export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) } 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) => { return new Promise((resolve, reject) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable')) reject(new Error('geolocation_unavailable'))
return 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( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed) finish(() => resolve(positionFromGeolocationPosition(pos)))
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
})
}, },
(err) => reject(err), (err) => {
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 } finish(() => reject(err))
},
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
) )
}) })
} }
+8
View File
@@ -2,6 +2,7 @@ import {
normalizeCourseAngleString, normalizeCourseAngleString,
normalizeWindDirectionString normalizeWindDirectionString
} from './courseAngle.js' } from './courseAngle.js'
import type { EntryCrewFields } from '../types/person.js'
export interface LogEventPayload { export interface LogEventPayload {
time: string time: string
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
motorHours?: number motorHours?: number
events: LogEventPayload[] events: LogEventPayload[]
entryCrew?: EntryCrewFields
} }
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> { 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 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 WORKDIR /app
RUN apk add --no-cache openssl libc6-compat 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 package*.json ./
COPY prisma ./prisma
# Install all dependencies (including devDependencies for tsc) # Install all dependencies (including devDependencies for tsc)
RUN npm ci RUN npm ci
# Copy Prisma schema and generate Client code
COPY prisma ./prisma
RUN npx prisma generate
# Copy source and compile TypeScript # Copy source and compile TypeScript
COPY src ./src COPY src ./src
COPY tsconfig.json ./ COPY tsconfig.json ./
@@ -26,8 +23,8 @@ RUN apk add --no-cache openssl libc6-compat
# Copy package configurations # Copy package configurations
COPY package*.json ./ COPY package*.json ./
# Install only production dependencies # Install only production dependencies (Prisma client copied from builder; skip postinstall)
RUN npm ci --omit=dev RUN npm ci --omit=dev --ignore-scripts
# Copy generated Prisma Client from builder stage # Copy generated Prisma Client from builder stage
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
+4 -2
View File
@@ -5,9 +5,11 @@
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "prisma generate && tsc",
"postinstall": "prisma generate",
"start": "node dist/index.js", "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": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
+26
View File
@@ -23,6 +23,7 @@ model User {
pushSubscriptions PushSubscription[] pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs? notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs? appearancePrefs UserAppearancePrefs?
personPool PersonPayload[]
} }
model PushSubscription { model PushSubscription {
@@ -86,6 +87,7 @@ model Logbook {
yachts YachtPayload[] yachts YachtPayload[]
crews CrewPayload[] crews CrewPayload[]
logbookCrewSelection LogbookCrewSelectionPayload?
deviations DeviationPayload[] deviations DeviationPayload[]
entries EntryPayload[] entries EntryPayload[]
photos PhotoPayload[] photos PhotoPayload[]
@@ -148,6 +150,30 @@ model CrewPayload {
@@unique([logbookId, payloadId]) @@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 { model DeviationPayload {
id String @id @default(uuid()) id String @id @default(uuid())
logbookId String @unique 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) => { router.get('/profile', requireUser, async (req: any, res) => {
try { try {
const user = await prisma.user.findUnique({ 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 yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } }) const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ 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 entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } }) const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } }) const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
@@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => {
yacht, yacht,
deviation, deviation,
crews, crews,
logbookCrewSelection,
entries, entries,
photos, photos,
gpsTracks gpsTracks
+33
View File
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } }) await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') { } else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } }) await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
} else { } else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` }) results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue continue
@@ -245,6 +247,29 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } 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( recordCollaboratorChange(
@@ -310,11 +335,19 @@ router.get('/pull', async (req: any, res) => {
const entries = await prisma.entryPayload.findMany({ where: { logbookId } }) const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } }) const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.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({ return res.json({
yacht, yacht,
deviation, deviation,
crews, crews,
logbookCrewSelection,
entries, entries,
photos, photos,
gpsTracks 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'
)
}