feat(vessel): Schiffsflotte im Profil und Logbuch-Auswahl

Benutzerweiter Vessel-Pool (E2E, Sync, Migration von Legacy-Yachts) mit
LogbookVesselSelection und LogbookVesselPicker. Profil mit Accordion
(Flotte & Crew); Demo und Onboarding-Tour inkl. profile_vessel_pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 21:25:08 +02:00
parent 182ea497d8
commit ec11dd8d2b
39 changed files with 2107 additions and 113 deletions
+90
View File
@@ -0,0 +1,90 @@
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
import type { VesselData } from '../types/vessel.js'
export function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value.trim()
return ''
}
export function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
}
export interface VesselFormInputs {
name: string
vesselType: string
lengthM: string
draftM: string
airDraftM: string
homePort: string
charterCompany: string
owner: string
registrationNumber: string
callSign: string
atis: string
mmsi: string
sails: string[]
photo: string | null
freshwaterCapacityL: string
fuelCapacityL: string
greywaterCapacityL: string
}
export function vesselDataToFormInputs(data: Partial<VesselData>): VesselFormInputs {
return {
name: data.name || '',
vesselType: data.vesselType || '',
lengthM: metricInputFromStored(data.lengthM),
draftM: metricInputFromStored(data.draftM),
airDraftM: metricInputFromStored(data.airDraftM),
homePort: data.homePort || '',
charterCompany: data.charterCompany || '',
owner: data.owner || '',
registrationNumber: data.registrationNumber || '',
callSign: data.callSign || '',
atis: data.atis || '',
mmsi: data.mmsi || '',
sails: data.sails || [],
photo: data.photo ?? null,
freshwaterCapacityL: tankCapacityInputFromStored(data.freshwaterCapacityL),
fuelCapacityL: tankCapacityInputFromStored(data.fuelCapacityL),
greywaterCapacityL: tankCapacityInputFromStored(data.greywaterCapacityL)
}
}
export function parseVesselFormInputs(inputs: VesselFormInputs): VesselData {
const parsedLengthM = parseOptionalMetricMeters(inputs.lengthM)
const parsedDraftM = parseOptionalMetricMeters(inputs.draftM)
const parsedAirDraftM = parseOptionalMetricMeters(inputs.airDraftM)
const parsedFreshwaterCapacityL = parseOptionalTankLiters(inputs.freshwaterCapacityL)
const parsedFuelCapacityL = parseOptionalTankLiters(inputs.fuelCapacityL)
const parsedGreywaterCapacityL = parseOptionalTankLiters(inputs.greywaterCapacityL)
return {
name: inputs.name.trim(),
vesselType: inputs.vesselType || undefined,
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
freshwaterCapacityL: parsedFreshwaterCapacityL,
fuelCapacityL: parsedFuelCapacityL,
greywaterCapacityL: parsedGreywaterCapacityL,
homePort: inputs.homePort.trim(),
charterCompany: inputs.charterCompany.trim(),
owner: inputs.owner.trim(),
registrationNumber: inputs.registrationNumber.trim(),
callSign: inputs.callSign.trim(),
atis: inputs.atis.trim(),
mmsi: inputs.mmsi.trim(),
sails: inputs.sails,
photo: inputs.photo
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { buildLogbookVesselSelection, vesselDataFromSnapshot, vesselToSnapshot } from './vesselSnapshot.js'
import type { VesselData } from '../types/vessel.js'
const sampleVessel: VesselData = {
name: 'Sea Breeze',
homePort: 'Kiel',
sails: ['Genoa'],
registrationNumber: 'DE-123'
}
describe('vesselSnapshot', () => {
it('builds selection with snapshot from pool', () => {
const pool = new Map<string, VesselData>([['v1', sampleVessel]])
const sel = buildLogbookVesselSelection('v1', pool)
expect(sel.activeVesselId).toBe('v1')
expect(sel.vesselSnapshot?.name).toBe('Sea Breeze')
expect(sel.vesselSnapshot?.id).toBe('v1')
})
it('returns empty selection when no vessel id', () => {
const sel = buildLogbookVesselSelection(null, new Map())
expect(sel.activeVesselId).toBeNull()
expect(sel.vesselSnapshot).toBeNull()
})
it('round-trips snapshot to vessel data', () => {
const snap = vesselToSnapshot('v1', sampleVessel)
const data = vesselDataFromSnapshot(snap)
expect(data?.name).toBe('Sea Breeze')
expect(data?.homePort).toBe('Kiel')
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { LogbookVesselSelectionData, VesselData, VesselSnapshot } from '../types/vessel.js'
export function vesselToSnapshot(id: string, data: VesselData): VesselSnapshot {
return {
id,
name: data.name,
vesselType: data.vesselType,
lengthM: data.lengthM,
draftM: data.draftM,
airDraftM: data.airDraftM,
homePort: data.homePort,
charterCompany: data.charterCompany,
owner: data.owner,
registrationNumber: data.registrationNumber,
callSign: data.callSign,
atis: data.atis,
mmsi: data.mmsi,
sails: data.sails ? [...data.sails] : [],
photo: data.photo ?? null,
freshwaterCapacityL: data.freshwaterCapacityL,
fuelCapacityL: data.fuelCapacityL,
greywaterCapacityL: data.greywaterCapacityL
}
}
export function buildLogbookVesselSelection(
activeVesselId: string | null,
pool: Map<string, VesselData>
): LogbookVesselSelectionData {
if (!activeVesselId) {
return { activeVesselId: null, vesselSnapshot: null }
}
const data = pool.get(activeVesselId)
if (!data) {
return { activeVesselId, vesselSnapshot: null }
}
return {
activeVesselId,
vesselSnapshot: vesselToSnapshot(activeVesselId, data)
}
}
export function vesselDataFromSnapshot(snapshot: VesselSnapshot | null): VesselData | null {
if (!snapshot) return null
const { id: _id, ...data } = snapshot
return data
}