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>
This commit is contained in:
@@ -5536,3 +5536,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
||||
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crew-selection-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.crew-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crew-selection-item input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+8
-5
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||
import { syncPersonPool } from './services/personPoolSync.js'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
@@ -161,6 +163,7 @@ function App() {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -701,7 +704,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -752,10 +755,10 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
skipperReadOnly={!isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -800,7 +803,7 @@ function App() {
|
||||
type="button"
|
||||
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>{t('nav.crew')}</span>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
|
||||
fixture
|
||||
|
||||
const demoSelection: LogbookCrewSelectionData = {
|
||||
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
activeCrewIds: logbookCrewSelection.activeCrewIds,
|
||||
snapshotsById: Object.fromEntries(
|
||||
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
|
||||
id,
|
||||
personToSnapshot(id, snap)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
||||
<LogbookCrewPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={personPool}
|
||||
preloadedSelection={demoSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,12 @@ export default function LogEntriesList({
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||
logbookId,
|
||||
previousEntry as Record<string, unknown> | null
|
||||
)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
@@ -285,6 +291,9 @@ export default function LogEntriesList({
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
|
||||
@@ -11,6 +11,9 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -108,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
@@ -168,6 +172,8 @@ export default function LogEntryEditor({
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -279,7 +285,8 @@ export default function LogEntryEditor({
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||
events: eventsOverride ?? events
|
||||
events: eventsOverride ?? events,
|
||||
entryCrew
|
||||
})
|
||||
}, [
|
||||
date, dayOfTravel, departure, destination,
|
||||
@@ -287,7 +294,8 @@ export default function LogEntryEditor({
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events
|
||||
events,
|
||||
entryCrew
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -706,6 +714,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
@@ -744,6 +753,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
@@ -2032,6 +2042,13 @@ export default function LogEntryEditor({
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||
|
||||
<EntryCrewSection
|
||||
logbookId={logbookId}
|
||||
readOnly={readOnly}
|
||||
value={entryCrew}
|
||||
onChange={setEntryCrew}
|
||||
/>
|
||||
|
||||
<SignatureSection
|
||||
readOnly={readOnly}
|
||||
disabled={saving}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||
|
||||
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
// Logbook data states
|
||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||
const [yacht, setYacht] = useState<any>(null)
|
||||
const [crews, setCrews] = useState<any[]>([])
|
||||
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||
emptyLogbookCrewSelection()
|
||||
)
|
||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
@@ -71,18 +78,53 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
setYacht(decYacht)
|
||||
|
||||
// Decrypt Crews
|
||||
const decCrews = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec
|
||||
if (data.logbookCrewSelection) {
|
||||
const decSel = await decryptJson(
|
||||
data.logbookCrewSelection.encryptedData,
|
||||
data.logbookCrewSelection.iv,
|
||||
data.logbookCrewSelection.tag,
|
||||
keyBuffer
|
||||
)
|
||||
if (decSel) {
|
||||
setLogbookCrewSelection({
|
||||
activeSkipperId: decSel.activeSkipperId ?? null,
|
||||
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
|
||||
snapshotsById:
|
||||
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
|
||||
? decSel.snapshotsById
|
||||
: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
setCrews(decCrews)
|
||||
|
||||
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
if (dec) {
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec as PersonData
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setLegacyCrews(decCrews)
|
||||
|
||||
if (!data.logbookCrewSelection && decCrews.length > 0) {
|
||||
const snapshotsById: LogbookCrewSelectionData['snapshotsById'] = {}
|
||||
let activeSkipperId: string | null = null
|
||||
const activeCrewIds: string[] = []
|
||||
for (const c of decCrews) {
|
||||
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
|
||||
if (c.payloadId === 'skipper' || c.data.role === 'skipper') {
|
||||
activeSkipperId = c.payloadId
|
||||
} else {
|
||||
activeCrewIds.push(c.payloadId)
|
||||
}
|
||||
}
|
||||
setLogbookCrewSelection({ activeSkipperId, activeCrewIds, snapshotsById })
|
||||
}
|
||||
|
||||
// Decrypt Entries
|
||||
const decEntries = []
|
||||
@@ -234,10 +276,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={crews}
|
||||
selectionOnly={true}
|
||||
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||
preloadedSelection={logbookCrewSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
|
||||
<PersonPoolForm />
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
|
||||
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
|
||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||
expect(prefsIndex).toBe(profileIndex + 1)
|
||||
expect(finishIndex).toBe(prefsIndex + 1)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||
expect(FULL_STEP_ORDER).toHaveLength(13)
|
||||
})
|
||||
|
||||
it('excludes profile, stats and feedback from demo tour', () => {
|
||||
|
||||
@@ -26,7 +26,8 @@ export type TourStepId =
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'profile_crew_pool'
|
||||
| 'nav_logbook_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'nav_profile'
|
||||
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'profile_crew_pool',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
|
||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||
'profile_crew_pool',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback'
|
||||
])
|
||||
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
profile_crew_pool: '[data-tour="profile-crew-pool"]',
|
||||
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]',
|
||||
nav_profile: '[data-tour="nav-profile"]',
|
||||
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
|
||||
return 250
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
if (stepId === 'profile_crew_pool') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setLogbookActive(false)
|
||||
nav.setProfileOpen(true)
|
||||
}
|
||||
if (stepId === 'nav_logbook_crew') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
if (stepId === 'nav_stats') {
|
||||
|
||||
@@ -607,6 +607,41 @@
|
||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stambesætning og skippere",
|
||||
"subtitle": "Administrer din personpulje her – skippere og besætning til alle logbøger. Vælg aktiv besætning per logbog og rejsedag fra puljen.",
|
||||
"loading": "Indlæser personpulje…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stambesætning",
|
||||
"add_skipper": "Tilføj skipper",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||
"no_crew": "Ingen besætningsmedlemmer i puljen endnu.",
|
||||
"delete_confirm": "Fjern denne person fra puljen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Besætning for denne logbog",
|
||||
"subtitle": "Vælg skipper og besætning for denne logbog. Nye rejsedage arver valget som standard.",
|
||||
"loading": "Indlæser besætning…",
|
||||
"active_skipper": "Skipper for denne logbog",
|
||||
"active_crew": "Besætning for denne logbog",
|
||||
"no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen besætning i puljen – tilføj i brugerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uden navn",
|
||||
"save": "Gem besætning",
|
||||
"saved": "Logbogbesætning gemt.",
|
||||
"selection_only_hint": "Du ser den besætning ejeren har valgt (delt logbog)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Besætning på denne rejsedag",
|
||||
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
|
||||
"day_skipper": "Skipper denne dag",
|
||||
"day_crew": "Besætning denne dag",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen besætning valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og besætningsprofiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
@@ -869,9 +904,13 @@
|
||||
"title": "Skibsdata",
|
||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besætningsliste",
|
||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
||||
"profile_crew_pool": {
|
||||
"title": "Stambesætning og skippere",
|
||||
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og besætning til alle logbøger."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Besætning per logbog",
|
||||
"body": "Vælg skipper og besætning fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-dashboard",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Schiffsdaten",
|
||||
"crew": "Crew-Liste",
|
||||
"crew": "Mannschaft",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
@@ -607,6 +607,41 @@
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Mannschaft.",
|
||||
"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": "Mannschaft für dieses Logbuch",
|
||||
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||
"loading": "Mannschaft 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": "Mannschaft speichern",
|
||||
"saved": "Mannschaft für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst die vom Eigner festgelegte Mannschaft (geteiltes Logbuch)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Mannschaft an diesem Reisetag",
|
||||
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
|
||||
"day_skipper": "Skipper an diesem Tag",
|
||||
"day_crew": "Crew an diesem Tag",
|
||||
"no_skipper": "Kein Skipper gewählt",
|
||||
"no_crew": "Keine Crew gewählt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
"skipper_section": "Skipper-Profil",
|
||||
@@ -847,7 +882,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Mannschaftsauswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
@@ -869,9 +904,13 @@
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Mannschaft pro Logbuch",
|
||||
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-Dashboard",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Vessel Profile",
|
||||
"crew": "Crew List",
|
||||
"crew": "Crew",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
@@ -607,6 +607,41 @@
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications."
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Core crew & skippers",
|
||||
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
|
||||
"loading": "Loading person pool…",
|
||||
"skippers_section": "Skippers",
|
||||
"crew_section": "Core crew",
|
||||
"add_skipper": "Add skipper",
|
||||
"add_crew": "Add crew member",
|
||||
"edit_skipper": "Edit skipper",
|
||||
"no_skippers": "No skippers in the pool yet.",
|
||||
"no_crew": "No crew members in the pool yet.",
|
||||
"delete_confirm": "Remove this person from the pool?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for this logbook",
|
||||
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
|
||||
"loading": "Loading crew…",
|
||||
"active_skipper": "Skipper for this logbook",
|
||||
"active_crew": "Crew for this logbook",
|
||||
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
|
||||
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
|
||||
"no_skipper": "No skipper selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save crew",
|
||||
"saved": "Logbook crew saved.",
|
||||
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew on this travel day",
|
||||
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
|
||||
"day_skipper": "Skipper on this day",
|
||||
"day_crew": "Crew on this day",
|
||||
"no_skipper": "No skipper selected",
|
||||
"no_crew": "No crew selected"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
"skipper_section": "Skipper Profile",
|
||||
@@ -869,9 +904,13 @@
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
"profile_crew_pool": {
|
||||
"title": "Core crew & skippers",
|
||||
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per logbook",
|
||||
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistics dashboard",
|
||||
|
||||
@@ -607,6 +607,41 @@
|
||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stammmannskap og skippere",
|
||||
"subtitle": "Hold personpoolen din her – skippere og mannskap for alle loggbøker. Velg aktivt mannskap per loggbok og reisedag fra poolen.",
|
||||
"loading": "Laster personpool…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stammmannskap",
|
||||
"add_skipper": "Legg til skipper",
|
||||
"add_crew": "Legg til mannskapsmedlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||
"no_crew": "Ingen mannskapsmedlemmer i poolen ennå.",
|
||||
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Mannskap for denne loggboken",
|
||||
"subtitle": "Velg skipper og mannskap for denne loggboken. Nye reisedager arver valget som standard.",
|
||||
"loading": "Laster mannskap…",
|
||||
"active_skipper": "Skipper for denne loggboken",
|
||||
"active_crew": "Mannskap for denne loggboken",
|
||||
"no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen mannskap i poolen – legg til i brukerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uten navn",
|
||||
"save": "Lagre mannskap",
|
||||
"saved": "Loggbokmannskap lagret.",
|
||||
"selection_only_hint": "Du ser mannskapet eieren har valgt (delt loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Mannskap på denne reisedagen",
|
||||
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||
"day_skipper": "Skipper denne dagen",
|
||||
"day_crew": "Mannskap denne dagen",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen mannskap valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og mannskapsprofiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
@@ -869,9 +904,13 @@
|
||||
"title": "Skipsdata",
|
||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Mannskapsliste",
|
||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammmannskap og skippere",
|
||||
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og mannskap for alle loggbøker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Mannskap per loggbok",
|
||||
"body": "Velg skipper og mannskap fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Dashbord for statistikk",
|
||||
|
||||
@@ -607,6 +607,41 @@
|
||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||
"push_error": "Push-meddelanden kunde inte aktiveras."
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stambesättning och skeppare",
|
||||
"subtitle": "Underhåll din personpool här – skeppare och besättning för alla loggböcker. Välj aktiv besättning per loggbok och resdag från poolen.",
|
||||
"loading": "Laddar personpool…",
|
||||
"skippers_section": "Skeppare",
|
||||
"crew_section": "Stambesättning",
|
||||
"add_skipper": "Lägg till skeppare",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"edit_skipper": "Redigera skeppare",
|
||||
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||
"no_crew": "Inga besättningsmedlemmar i poolen ännu.",
|
||||
"delete_confirm": "Ta bort denna person från poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Besättning för denna loggbok",
|
||||
"subtitle": "Välj skeppare och besättning för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||
"loading": "Laddar besättning…",
|
||||
"active_skipper": "Skeppare för denna loggbok",
|
||||
"active_crew": "Besättning 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 besättning i poolen – lägg till i användarprofilen först.",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"unnamed": "Namnlös",
|
||||
"save": "Spara besättning",
|
||||
"saved": "Loggbokbesättning sparad.",
|
||||
"selection_only_hint": "Du ser den besättning ägaren valt (delad loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Besättning 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": "Besättning denna dag",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"no_crew": "Ingen besättning vald"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och besättning",
|
||||
"skipper_section": "Skepparens profil",
|
||||
@@ -869,9 +904,13 @@
|
||||
"title": "Fartygsdata",
|
||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besättningslista",
|
||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
||||
"profile_crew_pool": {
|
||||
"title": "Stambesättning och skeppare",
|
||||
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och besättning för alla loggböcker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Besättning per loggbok",
|
||||
"body": "Välj skeppare och besättning från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Kontrollpanel för statistik",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 } 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: { skipperId: string | null; crewIds: string[] } = {
|
||||
skipperId: null,
|
||||
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') legacyIds.skipperId = poolId
|
||||
else legacyIds.crewIds.push(poolId)
|
||||
}
|
||||
|
||||
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||
if (!existingSelection && (legacyIds.skipperId || legacyIds.crewIds.length > 0)) {
|
||||
const selection = buildLogbookCrewSelection(
|
||||
legacyIds.skipperId,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -80,16 +80,41 @@ export interface LocalLogbookKey {
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface LocalPerson {
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookCrewSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
|
||||
payloadId: string // payloadId or logbookId depending on the type
|
||||
logbookId: string
|
||||
data: string // JSON representation of the local record
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UserSyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'person'
|
||||
payloadId: string
|
||||
data: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryDraftRecord {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
@@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
personPool!: Table<LocalPerson>
|
||||
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
userSyncQueue!: Table<UserSyncQueueItem>
|
||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||
|
||||
constructor() {
|
||||
@@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie {
|
||||
logbookKeys: 'logbookId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
this.version(8).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId',
|
||||
personPool: 'payloadId, updatedAt',
|
||||
logbookCrewSelections: 'logbookId, updatedAt',
|
||||
userSyncQueue: '++id, action, type, payloadId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||
import {
|
||||
buildDemoCrewRecords,
|
||||
buildDemoPersonPool,
|
||||
buildDemoEntryPayloads,
|
||||
buildDemoYachtData
|
||||
} from './demoLogbookData.js'
|
||||
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'crew') {
|
||||
await db.crews.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'logbookCrew') {
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
|
||||
const poolMap = new Map<string, PersonData>()
|
||||
for (const person of buildDemoPersonPool()) {
|
||||
poolMap.set(person.payloadId, person.data)
|
||||
const encrypted = await encryptJson(person.data, masterKey)
|
||||
await db.personPool.put({
|
||||
payloadId: person.payloadId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'person',
|
||||
payloadId: person.payloadId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
|
||||
return poolMap
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not available')
|
||||
|
||||
const yachtData = buildDemoYachtData()
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
for (const crew of buildDemoCrewRecords()) {
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
||||
}
|
||||
const poolMap = await seedPersonPool(masterKey, now)
|
||||
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
|
||||
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
|
||||
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
|
||||
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
|
||||
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
||||
'a0000001-0000-4000-8000-000000000003'
|
||||
] as const
|
||||
|
||||
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||
|
||||
export interface DemoDaySpec {
|
||||
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
|
||||
export interface PublicDemoFixture {
|
||||
title: string
|
||||
yacht: Record<string, unknown>
|
||||
/** @deprecated legacy share payload */
|
||||
crews: DemoCrewRecord[]
|
||||
personPool: DemoCrewRecord[]
|
||||
logbookCrewSelection: {
|
||||
activeSkipperId: string
|
||||
activeCrewIds: string[]
|
||||
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||
}
|
||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||
photos: never[]
|
||||
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||
return buildDemoCrewRecords()
|
||||
}
|
||||
|
||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||
data: {
|
||||
name: 'Demo Skipper',
|
||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
]
|
||||
}
|
||||
|
||||
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
|
||||
const skipper = pool.find((p) => p.data.role === 'skipper')
|
||||
const crew = pool.filter((p) => p.data.role === 'crew')
|
||||
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
|
||||
for (const p of pool) {
|
||||
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
|
||||
}
|
||||
return {
|
||||
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
|
||||
activeCrewIds: crew.map((c) => c.payloadId),
|
||||
snapshotsById
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const yacht = buildDemoYachtData()
|
||||
const crews = buildDemoCrewRecords()
|
||||
const personPool = buildDemoPersonPool()
|
||||
const crews = personPool
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||
const days = buildDemoDays()
|
||||
const entries: PublicDemoFixture['entries'] = []
|
||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
title,
|
||||
yacht,
|
||||
crews,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos: [],
|
||||
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
entryPayload: Record<string, unknown>
|
||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||
}> {
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||
const days = buildDemoDays()
|
||||
return days.map((day) => {
|
||||
const entryId = crypto.randomUUID()
|
||||
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { loadPersonPoolMap } from './personPool.js'
|
||||
|
||||
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function loadLogbookCrewSelection(
|
||||
logbookId: string
|
||||
): Promise<LogbookCrewSelectionData> {
|
||||
const record = await db.logbookCrewSelections.get(logbookId)
|
||||
if (!record) return emptyLogbookCrewSelection()
|
||||
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||
| LogbookCrewSelectionData
|
||||
| null
|
||||
if (!data) return emptyLogbookCrewSelection()
|
||||
|
||||
return {
|
||||
activeSkipperId: data.activeSkipperId ?? null,
|
||||
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
|
||||
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogbookCrewSelection(
|
||||
logbookId: string,
|
||||
selection: LogbookCrewSelectionData
|
||||
): Promise<void> {
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const encrypted = await encryptJson(selection, key)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbookCrew',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function saveLogbookCrewSelectionFromIds(
|
||||
logbookId: string,
|
||||
activeSkipperId: string | null,
|
||||
activeCrewIds: string[],
|
||||
poolOverride?: Map<string, PersonData>
|
||||
): Promise<LogbookCrewSelectionData> {
|
||||
const pool = poolOverride ?? (await loadPersonPoolMap())
|
||||
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
|
||||
await saveLogbookCrewSelection(logbookId, selection)
|
||||
return selection
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
reportSyncConflict,
|
||||
type SyncConflict
|
||||
} from './syncConflicts.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
@@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
case 'logbookCrew':
|
||||
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
logbookCrewSelection?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
@@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
|
||||
const serverTimes = new Map<string, string>()
|
||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||
if (server.logbookCrewSelection) {
|
||||
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||
}
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
@@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
|
||||
continue
|
||||
}
|
||||
|
||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
||||
const key =
|
||||
item.type === 'yacht'
|
||||
? 'yacht:' + logbookId
|
||||
: item.type === 'logbookCrew'
|
||||
? 'logbookCrew:' + logbookId
|
||||
: `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
@@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
||||
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
|
||||
await response.json()
|
||||
const serverSnapshot: PulledServerPayload = {
|
||||
yacht,
|
||||
deviation,
|
||||
logbookCrewSelection,
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
}
|
||||
|
||||
// 1. Sync Yacht Payload
|
||||
if (yacht) {
|
||||
@@ -314,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads
|
||||
// 2b. Sync Logbook Crew Selection
|
||||
if (logbookCrewSelection) {
|
||||
const local = await db.logbookCrewSelections.get(logbookId)
|
||||
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookCrewSelection.encryptedData,
|
||||
iv: logbookCrewSelection.iv,
|
||||
tag: logbookCrewSelection.tag,
|
||||
updatedAt: logbookCrewSelection.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads (legacy)
|
||||
const serverCrewMap = new Map<string, any>()
|
||||
if (crews && Array.isArray(crews)) {
|
||||
for (const c of crews) {
|
||||
@@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
syncAllInFlight++
|
||||
recomputeSyncingState()
|
||||
try {
|
||||
await syncPersonPool()
|
||||
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
|
||||
@@ -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 = 5
|
||||
|
||||
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
|
||||
return {
|
||||
activeSkipperId: null,
|
||||
activeCrewIds: [],
|
||||
snapshotsById: {}
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyEntryCrewFields(): EntryCrewFields {
|
||||
return {
|
||||
selectedSkipperId: null,
|
||||
selectedCrewIds: [],
|
||||
crewSnapshotsById: {}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString
|
||||
} from './courseAngle.js'
|
||||
import type { EntryCrewFields } from '../types/person.js'
|
||||
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
|
||||
trackSpeedAvgKn?: number
|
||||
motorHours?: number
|
||||
events: LogEventPayload[]
|
||||
entryCrew?: EntryCrewFields
|
||||
}
|
||||
|
||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
||||
}
|
||||
}
|
||||
|
||||
if (input.entryCrew) {
|
||||
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
||||
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
||||
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationPrefs UserNotificationPrefs?
|
||||
appearancePrefs UserAppearancePrefs?
|
||||
personPool PersonPayload[]
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
@@ -86,6 +87,7 @@ model Logbook {
|
||||
|
||||
yachts YachtPayload[]
|
||||
crews CrewPayload[]
|
||||
logbookCrewSelection LogbookCrewSelectionPayload?
|
||||
deviations DeviationPayload[]
|
||||
entries EntryPayload[]
|
||||
photos PhotoPayload[]
|
||||
@@ -148,6 +150,30 @@ model CrewPayload {
|
||||
@@unique([logbookId, payloadId])
|
||||
}
|
||||
|
||||
model PersonPayload {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
payloadId String
|
||||
encryptedData String
|
||||
iv String
|
||||
tag String
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, payloadId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model LogbookCrewSelectionPayload {
|
||||
id String @id @default(uuid())
|
||||
logbookId String @unique
|
||||
encryptedData String
|
||||
iv String
|
||||
tag String
|
||||
updatedAt DateTime @updatedAt
|
||||
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model DeviationPayload {
|
||||
id String @id @default(uuid())
|
||||
logbookId String @unique
|
||||
|
||||
@@ -504,6 +504,75 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/person-pool', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const persons = await prisma.personPayload.findMany({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
return res.json({ persons })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/person-pool-get')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
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) {
|
||||
return sendInternalError(res, error, 'auth/person-pool-push')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/profile', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||
where: { logbookId }
|
||||
})
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
@@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
yacht,
|
||||
deviation,
|
||||
crews,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
|
||||
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'gpsTrack') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else if (type === 'logbookCrew') {
|
||||
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
|
||||
} else {
|
||||
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
||||
continue
|
||||
@@ -245,6 +247,19 @@ router.post('/push', async (req: any, res) => {
|
||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||
})
|
||||
}
|
||||
} else if (type === 'logbookCrew') {
|
||||
{
|
||||
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
continue
|
||||
}
|
||||
await prisma.logbookCrewSelectionPayload.upsert({
|
||||
where: { logbookId },
|
||||
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recordCollaboratorChange(
|
||||
@@ -310,11 +325,15 @@ router.get('/pull', async (req: any, res) => {
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||
where: { logbookId }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
yacht,
|
||||
deviation,
|
||||
crews,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
|
||||
Reference in New Issue
Block a user