Compare commits

...

6 Commits

Author SHA1 Message Date
elpatron b9c908169b chore: release v0.1.0.78 2026-06-01 13:44:36 +02:00
elpatron e6bde5c525 fix: shorten travel day badge to "Reisetag x"
Use travel_day_number i18n key in journal cards and live log subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:27 +02:00
elpatron eab7b86c0b chore: release v0.1.0.77 2026-06-01 13:28:24 +02:00
elpatron b86789ae4c fix: show profile button while a logbook is open
Extract ProfileHeaderButton and open UserProfilePage from any screen
without leaving the active logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:03 +02:00
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
17 changed files with 179 additions and 47 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.76
0.1.0.79
+20 -12
View File
@@ -44,6 +44,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
@@ -557,22 +558,27 @@ function App() {
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
</div>
)
}
@@ -622,6 +628,8 @@ function App() {
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
<FeedbackHeaderButton
+5 -4
View File
@@ -474,7 +474,10 @@ export default function LiveLogView({
try {
let data: Record<string, unknown>
try {
data = await fetchOpenWeatherCurrent({ lat, lon: lng })
data = await fetchOpenWeatherCurrent(
{ lat, lon: lng },
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
@@ -515,7 +518,6 @@ export default function LiveLogView({
await appendQuickEvents(logbookId, id, partials)
await refreshEntry(id)
showUndo()
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'weather_owm' })
} catch (err: unknown) {
console.error('Live log OWM weather save failed:', err)
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
@@ -575,7 +577,6 @@ export default function LiveLogView({
setModal('none')
setPhotoCaption('')
showUndo('photo')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'photo' })
} catch (err: unknown) {
console.error('Live log photo save failed:', err)
void showAlert(
@@ -764,7 +765,7 @@ export default function LiveLogView({
<h2>{t('logs.live_title')}</h2>
{date && (
<p className="live-log-subtitle">
{t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()}
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
</p>
)}
</div>
+1 -1
View File
@@ -474,7 +474,7 @@ export default function LogEntriesList({
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
{t('logs.travel_day_number', { number: item.dayOfTravel })}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
+6 -2
View File
@@ -900,7 +900,10 @@ export default function LogEntryEditor({
}
try {
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const data = await fetchOpenWeatherCurrent(
{ q: locationQuery },
{ analyticsSource: 'entry_editor_gps_lookup' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
@@ -955,7 +958,8 @@ export default function LogEntryEditor({
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
: { q: fallbackLocation },
{ analyticsSource: 'entry_editor' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
+3 -14
View File
@@ -8,9 +8,10 @@ import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -74,7 +75,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -370,18 +370,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)}
</div>
{/* Skipper profile */}
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
<ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
+2 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
"date": "dato",
"day_of_travel": "Rejsedag / rejsedag",
"day_of_travel": "Rejsedag",
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)",
"route": "Rejse fra/til",
+2 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
+2 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
+2 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
"date": "dato",
"day_of_travel": "Reisens dag / reisedag",
"day_of_travel": "Reisedag",
"travel_day_number": "Reisedag {{number}}",
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
+2 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
"date": "datum",
"day_of_travel": "Resedag / resedag",
"day_of_travel": "Resedag",
"travel_day_number": "Resedag {{number}}",
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
+6 -1
View File
@@ -39,9 +39,14 @@ export const PlausibleEvents = {
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+3
View File
@@ -55,6 +55,9 @@ export async function saveEntryPhoto(options: {
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from './analytics.js'
const apiFetch = vi.fn()
const trackPlausibleEvent = vi.fn()
vi.mock('./api.js', () => ({ apiFetch }))
vi.mock('./analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
vi.mock('./userPreferences.js', () => ({
getOwmApiKeyForActiveUser: () => ''
}))
describe('fetchOpenWeatherCurrent', () => {
beforeEach(() => {
apiFetch.mockReset()
trackPlausibleEvent.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
})
const { fetchOpenWeatherCurrent } = await import('./weather.js')
await fetchOpenWeatherCurrent(
{ lat: '54.0', lon: '10.0' },
{ analyticsSource: 'live_log' }
)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: 'live_log'
})
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'fail' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
await expect(
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
).rejects.toBeInstanceOf(WeatherApiError)
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
})
+19 -5
View File
@@ -1,5 +1,10 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
import {
type OwmAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -13,11 +18,14 @@ export class WeatherApiError extends Error {
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
export async function fetchOpenWeatherCurrent(
params: {
lat?: string
lon?: string
q?: string
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
@@ -59,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('Weather API rejected the request')
}
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: options.analyticsSource
})
}
return data
}
+19 -2
View File
@@ -36,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
@@ -80,6 +82,18 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `comment` | Kommentar |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
| `source` | Auslöser |
|----------|----------|
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
@@ -106,7 +120,8 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
## Entwicklung
@@ -117,6 +132,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
```