diff --git a/client/src/App.css b/client/src/App.css index 2a2159f..e09d8d9 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 76f2666..69660db 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + + )} + + + ) +} + +export function selectionFromSnapshots( + snapshotsById: Record +): 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 + } +} diff --git a/client/src/components/PersonPoolForm.tsx b/client/src/components/PersonPoolForm.tsx new file mode 100644 index 0000000..ed5fc21 --- /dev/null +++ b/client/src/components/PersonPoolForm.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [formRole, setFormRole] = useState('crew') + const [form, setForm] = useState(emptyPerson('crew')) + const [saving, setSaving] = useState(false) + const [photoError, setPhotoError] = useState(null) + const fileRef = React.useRef(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 ( +
+ +

{t('person_pool.loading')}

+
+ ) + } + + const renderCard = (person: DecryptedPerson) => ( +
+
+
+ {person.data.photo ? ( + + ) : ( +
+ +
+ )} +

{person.data.name}

+
+
+ + +
+
+ {person.data.phone && ( +

+ {t('crew.phone')}: {person.data.phone} +

+ )} +
+ ) + + return ( +
+
+ +

{t('person_pool.title')}

+
+

{t('person_pool.subtitle')}

+ {error &&
{error}
} + +
+

{t('person_pool.skippers_section')}

+ {!showForm && ( + + )} +
+ {skippers.length === 0 ? ( +

{t('person_pool.no_skippers')}

+ ) : ( +
{skippers.map(renderCard)}
+ )} + +
+

{t('person_pool.crew_section')}

+ {!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && ( + + )} +
+ {crewList.length === 0 ? ( +

{t('person_pool.no_crew')}

+ ) : ( +
{crewList.map(renderCard)}
+ )} + + {showForm && ( +
void handleSave(e)} className="member-editor-card glass mt-6"> +
+

+ {editingId + ? formRole === 'skipper' + ? t('person_pool.edit_skipper') + : t('crew.edit_crew') + : formRole === 'skipper' + ? t('person_pool.add_skipper') + : t('crew.add_crew')} +

+ +
+
+
+
fileRef.current?.click()}> + {form.photo ? ( + + ) : ( +
+ +
+ )} +
+ +
+
+ { + 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 &&
{photoError}
} +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + required + /> +
+
+ + setForm((f) => ({ ...f, address: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, birthDate: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, phone: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, nationality: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, passportNumber: e.target.value }))} + /> +
+
+
+ +
+
+ )} +
+ ) +} diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx index 3c07504..53b83a0 100644 --- a/client/src/components/ReadOnlyViewer.tsx +++ b/client/src/components/ReadOnlyViewer.tsx @@ -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(null) - const [crews, setCrews] = useState([]) + const [logbookCrewSelection, setLogbookCrewSelection] = useState( + emptyLogbookCrewSelection() + ) + const [legacyCrews, setLegacyCrews] = useState([]) const [entries, setEntries] = useState([]) const [photos, setPhotos] = useState([]) const [gpsTracks, setGpsTracks] = useState([]) @@ -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' && ( - 0 ? legacyCrews : undefined} + preloadedSelection={logbookCrewSelection} /> )} diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx index 2f89d43..397bbea 100644 --- a/client/src/components/UserProfilePage.tsx +++ b/client/src/components/UserProfilePage.tsx @@ -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 + +
diff --git a/client/src/context/AppTourContext.test.ts b/client/src/context/AppTourContext.test.ts index f448541..e3b5983 100644 --- a/client/src/context/AppTourContext.test.ts +++ b/client/src/context/AppTourContext.test.ts @@ -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', () => { diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx index f86da7e..1b06d06 100644 --- a/client/src/context/AppTourContext.tsx +++ b/client/src/context/AppTourContext.tsx @@ -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([ 'entry_open', 'entry_track', 'nav_vessel', - 'nav_crew', + 'nav_logbook_crew', 'nav_stats', 'nav_feedback' ]) @@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial> = { 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') { diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index ac182e3..1f5380a 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -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", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index b38a829..2ef45e5 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 2eb7d6a..45a233b 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 58ed676..f12049a 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -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", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 8f7f9d5..23c1692 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -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", diff --git a/client/src/services/crewMigration.ts b/client/src/services/crewMigration.ts new file mode 100644 index 0000000..3ab4549 --- /dev/null +++ b/client/src/services/crewMigration.ts @@ -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 { + 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() + const poolData = new Map() + + 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) + } +} diff --git a/client/src/services/db.ts b/client/src/services/db.ts index d6f9fab..99f13d7 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -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 nmeaArchives!: Table logbookKeys!: Table + personPool!: Table + logbookCrewSelections!: Table syncQueue!: Table + userSyncQueue!: Table entryDrafts!: Table 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' + }) } } diff --git a/client/src/services/demoLogbook.ts b/client/src/services/demoLogbook.ts index ca20ea4..d441a4b 100644 --- a/client/src/services/demoLogbook.ts +++ b/client/src/services/demoLogbook.ts @@ -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> { + const poolMap = new Map() + 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 { + 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 { diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts index f811bcb..fa44d65 100644 --- a/client/src/services/demoLogbookData.ts +++ b/client/src/services/demoLogbookData.ts @@ -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 + /** @deprecated legacy share payload */ crews: DemoCrewRecord[] + personPool: DemoCrewRecord[] + logbookCrewSelection: { + activeSkipperId: string + activeCrewIds: string[] + snapshotsById: Record + } entries: Array & { payloadId: string }> gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }> photos: never[] @@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record { } } +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 = {} + 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 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 diff --git a/client/src/services/logbookCrewSelection.ts b/client/src/services/logbookCrewSelection.ts new file mode 100644 index 0000000..d71b4f1 --- /dev/null +++ b/client/src/services/logbookCrewSelection.ts @@ -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 { + 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 { + 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 { + 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 +): Promise { + const pool = poolOverride ?? (await loadPersonPoolMap()) + const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool) + await saveLogbookCrewSelection(logbookId, selection) + return selection +} diff --git a/client/src/services/personPool.ts b/client/src/services/personPool.ts new file mode 100644 index 0000000..69844e4 --- /dev/null +++ b/client/src/services/personPool.ts @@ -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 { + 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> { + 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 { + 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 { + 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') +} diff --git a/client/src/services/personPoolSync.ts b/client/src/services/personPoolSync.ts new file mode 100644 index 0000000..c9dc940 --- /dev/null +++ b/client/src/services/personPoolSync.ts @@ -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 { + if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return + + await pushPersonPool() + await pullPersonPool() +} + +async function pushPersonPool(): Promise { + 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 { + 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() + 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) + } +} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index cabe948..8bd7f88 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -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() @@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise { 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 { 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() 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 { 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 { } } - // 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() if (crews && Array.isArray(crews)) { for (const c of crews) { @@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise { syncAllInFlight++ recomputeSyncingState() try { + await syncPersonPool() + // 1. Fetch latest logbook lists first (synchronizes db.logbooks index) const logbooks = await db.logbooks.toArray() diff --git a/client/src/types/person.ts b/client/src/types/person.ts new file mode 100644 index 0000000..c81367d --- /dev/null +++ b/client/src/types/person.ts @@ -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 +} + +export interface EntryCrewFields { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} + +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: {} + } +} diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index f004af4..52e0fb0 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -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 { @@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record +): Record { + const snapshotsById: Record = {} + 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 +): LogbookCrewSelectionData { + return { + activeSkipperId, + activeCrewIds: [...activeCrewIds], + snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool) + } +} + +export function entryCrewFromLogbookSelection( + selection: LogbookCrewSelectionData +): { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} { + return { + selectedSkipperId: selection.activeSkipperId, + selectedCrewIds: [...selection.activeCrewIds], + crewSnapshotsById: { ...selection.snapshotsById } + } +} + +export function entryCrewFromPreviousEntry(entry: Record): { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} { + 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) + : {} + return { selectedSkipperId, selectedCrewIds, crewSnapshotsById } +} diff --git a/client/src/utils/resizeImageFile.ts b/client/src/utils/resizeImageFile.ts new file mode 100644 index 0000000..1393f3f --- /dev/null +++ b/client/src/utils/resizeImageFile.ts @@ -0,0 +1,39 @@ +/** Resize and compress an image file to a JPEG data URL (max 800×600). */ +export function resizeImageFile(file: File): Promise { + 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) + }) +} diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ab8b89f..a0ca342 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ad2ef18..0a87f7f 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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({ diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts index 0b90bb8..d74c97f 100644 --- a/server/src/routes/collaboration.ts +++ b/server/src/routes/collaboration.ts @@ -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 diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 108826b..4f8ea78 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -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