diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 873196d..07d00be 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -152,6 +152,7 @@ export default function LiveLogView({ const [modal, setModal] = useState('none') const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) + const [isOnline, setIsOnline] = useState(navigator.onLine) const [commentText, setCommentText] = useState('') const [valueInput, setValueInput] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('') @@ -269,6 +270,17 @@ export default function LiveLogView({ } }, [logbookId, applyLoadedEntry, t]) + useEffect(() => { + const handleOnline = () => setIsOnline(true) + const handleOffline = () => setIsOnline(false) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + useEffect(() => { void runInit() return () => { @@ -503,6 +515,10 @@ export default function LiveLogView({ const handleFetchOwmWeather = () => { if (!entryId || busy || weatherOwmLoading) return + if (!isOnline) { + void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn')) + return + } const position = getLastPositionFixWithin( events, @@ -533,6 +549,10 @@ export default function LiveLogView({ { analyticsSource: 'live_log' } ) } catch (err) { + if (err instanceof WeatherApiError && err.code === 'OFFLINE') { + void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn')) + return + } if (err instanceof WeatherApiError && err.code === 'NO_KEY') { void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn')) return diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index d3600f0..3ff8ab3 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -538,7 +538,7 @@ export default function LogEntryEditor({ }, [logbookId]) useEffect(() => { - if (!canSignSkipper || readOnly) { + if (!canSignSkipper || readOnly || !isOnline) { setAiSummaryRemaining(null) return } @@ -553,7 +553,7 @@ export default function LogEntryEditor({ console.warn('Failed to load AI summary usage:', err) }) return () => { cancelled = true } - }, [canSignSkipper, readOnly, logbookId, entryId]) + }, [canSignSkipper, readOnly, isOnline, logbookId, entryId]) useEffect(() => { const seq = ++entryHashSeqRef.current @@ -986,6 +986,10 @@ export default function LogEntryEditor({ showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.') return } + if (!isOnline) { + showAlert(t('logs.weather_offline')) + return + } try { const data = await fetchOpenWeatherCurrent( @@ -999,6 +1003,10 @@ export default function LogEntryEditor({ showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`) } } catch (e) { + if (e instanceof WeatherApiError && e.code === 'OFFLINE') { + showAlert(t('logs.weather_offline')) + return + } if (e instanceof WeatherApiError && e.code === 'NO_KEY') { showAlert(t('settings.no_key')) return @@ -1025,6 +1033,11 @@ export default function LogEntryEditor({ } const handleFetchWeather = async () => { + if (!isOnline) { + showAlert(t('logs.weather_offline')) + return + } + const localToday = new Date() const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}` @@ -1066,6 +1079,10 @@ export default function LogEntryEditor({ showAlert(t('settings.weather_success')) } catch (err) { + if (err instanceof WeatherApiError && err.code === 'OFFLINE') { + showAlert(t('logs.weather_offline')) + return + } if (err instanceof WeatherApiError && err.code === 'NO_KEY') { showAlert(t('settings.no_key')) return @@ -1079,6 +1096,10 @@ export default function LogEntryEditor({ const handleGenerateAiSummary = async () => { if (!canSignSkipper || readOnly || aiSummaryLoading) return + if (!isOnline) { + setAiSummaryError(t('logs.ai_summary_offline')) + return + } if (aiSummaryRemaining === 0) { setAiSummaryError(t('logs.ai_summary_error_rate_limited')) return @@ -1135,7 +1156,9 @@ export default function LogEntryEditor({ }) } catch (err) { if (err instanceof TravelDaySummaryApiError) { - if (err.code === 'NO_KEY') { + if (err.code === 'OFFLINE') { + setAiSummaryError(t('logs.ai_summary_offline')) + } else if (err.code === 'NO_KEY') { setAiSummaryError(t('logs.ai_summary_error_no_key')) } else if (err.code === 'RATE_LIMITED') { setAiSummaryError(t('logs.ai_summary_error_rate_limited')) diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 283a2bc..c81e906 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -358,6 +358,7 @@ "event_remarks": "Bemærkninger / hændelser", "gps_btn": "Hent GPS-koordinater", "weather_btn": "OpenWeatherMap Kald vejret op", + "weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.", "event_wind_pressure": "Lufttryk (hPa)", "event_heel": "Krængning (°)", "event_sails": "Sejlhåndtering/motor", @@ -381,6 +382,7 @@ "ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.", "ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.", "ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.", + "ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.", "photos_title": "Vedhæftede billeder (E2E-krypteret)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index ecb2346..b316719 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -358,6 +358,7 @@ "event_remarks": "Bemerkungen / Vorkommnisse", "gps_btn": "GPS-Koordinaten abrufen", "weather_btn": "OpenWeatherMap Wetter abrufen", + "weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.", "event_wind_pressure": "Luftdruck (hPa)", "event_heel": "Krängung (°)", "event_sails": "Segelführung / Motor", @@ -381,6 +382,7 @@ "ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.", "ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.", "ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.", + "ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.", "photos_title": "Foto-Anhänge (E2E-verschlüsselt)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index cdf8659..020d5d2 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -358,6 +358,7 @@ "event_remarks": "Remarks / Events", "gps_btn": "Get GPS Location", "weather_btn": "Fetch OpenWeatherMap Weather", + "weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.", "event_wind_pressure": "Barometer (hPa)", "event_heel": "Heel Angle (°)", "event_sails": "Sails / Motor Status", @@ -381,6 +382,7 @@ "ai_summary_error_no_key": "No OpenRouter API key configured on the server.", "ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.", "ai_summary_error_forbidden": "Only the skipper may generate AI summaries.", + "ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.", "photos_title": "Photo Attachments (E2E Encrypted)", "photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index e02244c..f543dac 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -358,6 +358,7 @@ "event_remarks": "Merknader / hendelser", "gps_btn": "Hent GPS-koordinater", "weather_btn": "OpenWeatherMap Ring opp været", + "weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.", "event_wind_pressure": "Lufttrykk (hPa)", "event_heel": "Helning (°)", "event_sails": "Seilhåndtering / motor", @@ -381,6 +382,7 @@ "ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.", "ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.", "ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.", + "ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.", "photos_title": "Bildevedlegg (E2E-kryptert)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 03004bf..b4cbe56 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -358,6 +358,7 @@ "event_remarks": "Anmärkningar / incidenter", "gps_btn": "Hämta GPS-koordinater", "weather_btn": "OpenWeatherMap Ring upp väder", + "weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.", "event_wind_pressure": "Lufttryck (hPa)", "event_heel": "Krängning (°)", "event_sails": "Segelhantering / motor", @@ -381,6 +382,7 @@ "ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.", "ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.", "ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.", + "ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.", "photos_title": "Fotobilagor (E2E-krypterade)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", diff --git a/client/src/services/aiSummary.ts b/client/src/services/aiSummary.ts index 7651344..511c910 100644 --- a/client/src/services/aiSummary.ts +++ b/client/src/services/aiSummary.ts @@ -5,11 +5,11 @@ import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayl import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' export class TravelDaySummaryApiError extends Error { - code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' + code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' constructor( message: string, - code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED' + code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED' ) { super(message) this.name = 'TravelDaySummaryApiError' @@ -146,6 +146,10 @@ export async function generateTravelDaySummary(params: { language: string context: TravelDaySummaryContext }): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> { + if (!navigator.onLine) { + throw new TravelDaySummaryApiError('Offline', 'OFFLINE') + } + const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS) diff --git a/client/src/services/weather.test.ts b/client/src/services/weather.test.ts index b765291..a7b6685 100644 --- a/client/src/services/weather.test.ts +++ b/client/src/services/weather.test.ts @@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => { }) }) + it('throws OFFLINE when navigator.onLine is false', async () => { + vi.stubGlobal('navigator', { ...navigator, onLine: false }) + + const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js') + const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e) + expect(err).toBeInstanceOf(WeatherApiError) + expect((err as InstanceType).code).toBe('OFFLINE') + + expect(apiFetch).not.toHaveBeenCalled() + }) + it('does not track when the API request fails', async () => { apiFetch.mockResolvedValue({ ok: false, diff --git a/client/src/services/weather.ts b/client/src/services/weather.ts index 5b0f5ad..9b3e55c 100644 --- a/client/src/services/weather.ts +++ b/client/src/services/weather.ts @@ -7,9 +7,9 @@ import { } from './analytics.js' export class WeatherApiError extends Error { - code: 'NO_KEY' | 'REQUEST_FAILED' + code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' - constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { + constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { super(message) this.name = 'WeatherApiError' this.code = code @@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent( }, options?: { analyticsSource: OwmAnalyticsSource } ): Promise> { + if (!navigator.onLine) { + throw new WeatherApiError('Offline', 'OFFLINE') + } + const searchParams = new URLSearchParams() if (params.lat && params.lon) {