feat: Demo-Logbuch und Onboarding-Tour bei Registrierung

Neue Nutzer erhalten automatisch ein Demo-Logbuch mit drei Ostsee-Reisetagen
und eine interaktive App-Tour; die Tour kann in den Einstellungen erneut gestartet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 17:59:02 +02:00
co-authored by Cursor
parent 646d316a36
commit 0da855381d
20 changed files with 5549 additions and 23 deletions
+16
View File
@@ -0,0 +1,16 @@
export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}`
}
export function isTourCompleted(userId: string | null): boolean {
if (!userId) return true
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
}
export function markTourCompleted(userId: string): void {
localStorage.setItem(getTourCompletedKey(userId), '1')
}
export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId))
}
+1
View File
@@ -254,6 +254,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
localStorage.setItem('active_username', username)
localStorage.setItem('active_userid', result.userId)
rememberUsername(username)
sessionStorage.setItem('seed_demo_logbook', '1')
}
return {
+12
View File
@@ -6,6 +6,7 @@ export interface LocalLogbook {
updatedAt: string
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
isDemo?: number // 1 = demo logbook seeded at registration
}
export interface LocalYacht {
@@ -132,6 +133,17 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(5).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',
logbookKeys: 'logbookId'
})
}
}
+331
View File
@@ -0,0 +1,331 @@
import { createLogbook } from './logbook.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
export function getDemoLogbookStorageKey(userId: string): string {
return `demo_logbook_id_${userId}`
}
export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}`
}
interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: isDe ? 'Kiel' : 'Kiel',
destination: isDe ? 'Laboe' : 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: isDe ? 'NW' : 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: isDe ? 'Schleimünde' : 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
payloadId: string,
data: unknown,
now: string
): Promise<void> {
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
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,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'gpsTrack') {
await db.gpsTracks.put({
entryId: payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de')
const yachtData = {
name: isDe ? 'Seeadler' : 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe
? ['Großsegel', 'Genua', 'Spinnaker']
: ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const crewId = crypto.randomUUID()
const crewData = {
name: isDe ? 'Anna Müller' : 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
}
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
}
export interface DemoSeedResult {
logbookId: string
title: string
firstEntryId: string
}
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (existingId) {
const existing = await db.logbooks.get(existingId)
if (existing) {
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
}
if (!shouldSeed) return null
sessionStorage.removeItem(SEED_DEMO_FLAG)
const title = i18n.t('demo.logbook_title')
const logbook = await createLogbook(title)
const logbookId = logbook.id
await db.logbooks.update(logbookId, { isDemo: 1 })
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not available for demo seed')
const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays()
let firstEntryId = ''
for (const day of days) {
const entryId = crypto.randomUUID()
if (!firstEntryId) firstEntryId = entryId
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
}
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
return { logbookId, title, firstEntryId }
}
export function getStoredDemoLogbookId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoLogbookStorageKey(userId))
}
export function getStoredDemoFirstEntryId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
+6 -2
View File
@@ -11,6 +11,7 @@ export interface DecryptedLogbook {
updatedAt: string
isSynced: boolean
isShared: boolean
isDemo?: boolean
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -98,12 +99,14 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
}
// Update Dexie database cache
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0
isShared: lb.userId !== userId ? 1 : 0,
isDemo: localById.get(lb.id)?.isDemo
}))
// Clear existing cache for this user and insert new ones
@@ -126,7 +129,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
title,
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: lb.isShared === 1
isShared: lb.isShared === 1,
isDemo: lb.isDemo === 1
})
}