diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index f3c5f94..6f754a3 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -68,7 +68,8 @@ import { import { fetchTidesForEntry, fetchTidesForStationChoice, - type TideFetchNeedsStationPick + type TideFetchNeedsStationPick, + type TideFetchResult } from '../utils/tideFetch.js' import { geolocationErrorI18nKey, @@ -851,17 +852,10 @@ export default function LiveLogView({ setError(null) void (async () => { try { - const loaded = await loadEntry(logbookId, entryId) - const eventsForLocation = loaded - ? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || []) - : events - const entryDateForLocation = loaded ? String(loaded.data.date || date) : date - const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure - const location = resolveTideFetchLocation({ - events: eventsForLocation, - entryDate: entryDateForLocation, - departure: departureForLocation + events, + entryDate: date, + departure }) if ('error' in location) { void showAlert( @@ -875,19 +869,20 @@ export default function LiveLogView({ const outcome = await fetchTidesForEntry({ fetchLocation: location, - entryDate: entryDateForLocation, + entryDate: date, analyticsSource: 'live_log' }) - if (outcome.kind === 'pick_station') { - setTideStationPicker(outcome) + if ('kind' in outcome && outcome.kind === 'pick_station') { + setTideStationPicker(outcome as TideFetchNeedsStationPick) return } + const result = outcome as TideFetchResult setTidePreview({ - highWater: outcome.highWater, - lowWater: outcome.lowWater, - location: outcome.location + highWater: result.highWater, + lowWater: result.lowWater, + location: result.location }) setModal('tides') } catch (err) { diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index fbd47fd..8914dd5 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -54,7 +54,8 @@ import { import { fetchTidesForEntry, fetchTidesForStationChoice, - type TideFetchNeedsStationPick + type TideFetchNeedsStationPick, + type TideFetchResult } from '../utils/tideFetch.js' import { buildTravelDayContext, @@ -62,7 +63,7 @@ import { generateTravelDaySummary, TravelDaySummaryApiError } from '../services/aiSummary.js' -import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js' +import { tryDecryptEntryPayload } from '../services/quickEventLog.js' import { getAiAuthorized } from '../services/userPreferences.js' import { getDecryptedTrack, @@ -1351,17 +1352,10 @@ export default function LogEntryEditor({ setTidesLoading(true) try { - const loaded = await loadEntry(logbookId, entryId) - const eventsForLocation = loaded - ? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || []) - : events - const entryDateForLocation = loaded ? String(loaded.data.date || date) : date - const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure - const location = resolveTideFetchLocation({ - events: eventsForLocation, - entryDate: entryDateForLocation, - departure: departureForLocation + events, + entryDate: date, + departure }) if ('error' in location) { if (location.error === 'stale') { @@ -1374,16 +1368,16 @@ export default function LogEntryEditor({ const outcome = await fetchTidesForEntry({ fetchLocation: location, - entryDate: entryDateForLocation, + entryDate: date, analyticsSource: 'entry_editor' }) - if (outcome.kind === 'pick_station') { - setTideStationPicker(outcome) + if ('kind' in outcome && outcome.kind === 'pick_station') { + setTideStationPicker(outcome as TideFetchNeedsStationPick) return } - applyTideFetchResult(outcome) + applyTideFetchResult(outcome as TideFetchResult) } catch (err) { if (err instanceof TidesApiError) { if (err.code === 'OFFLINE') { diff --git a/server/src/utils/openMeteoTides.ts b/server/src/utils/openMeteoTides.ts index fa1449c..8a35aef 100644 --- a/server/src/utils/openMeteoTides.ts +++ b/server/src/utils/openMeteoTides.ts @@ -224,19 +224,62 @@ function scoreGeocodingResult(query: string, result: GeocodingResult): number { return score } -export async function geocodePlace(query: string): Promise { +function replaceGermanDigraphs(str: string): string { + return str + .replace(/ae/g, 'ä') + .replace(/oe/g, 'ö') + .replace(/ue/g, 'ü') + .replace(/Ae/g, 'Ä') + .replace(/Oe/g, 'Ö') + .replace(/Ue/g, 'Ü') + .replace(/AE/g, 'Ä') + .replace(/OE/g, 'Ö') + .replace(/UE/g, 'Ü'); +} + +async function doGeocode(q: string): Promise { const url = new URL(GEOCODING_API) - url.searchParams.set('name', query.trim()) + url.searchParams.set('name', q.trim()) url.searchParams.set('count', '10') url.searchParams.set('language', 'de') - const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString()) - const results = data.results ?? [] - if (results.length === 0) return null - - return [...results].sort((a, b) => scoreGeocodingResult(query, b) - scoreGeocodingResult(query, a))[0] + console.log(`[geocodePlace] Fetching URL: ${url.toString()}`); + try { + const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString()) + const results = data.results ?? [] + if (results.length === 0) { + return null + } + const sorted = [...results].sort((a, b) => scoreGeocodingResult(q, b) - scoreGeocodingResult(q, a)) + return sorted[0] + } catch (err) { + console.error(`[geocodePlace] Geocoding API request failed for "${q}":`, err) + return null + } } +export async function geocodePlace(query: string): Promise { + console.log(`[geocodePlace] query: "${query}" (length: ${query.length})`); + + let match = await doGeocode(query) + if (!match) { + const fallbackQuery = replaceGermanDigraphs(query) + if (fallbackQuery !== query) { + console.log(`[geocodePlace] No results for "${query}". Trying fallback query: "${fallbackQuery}"`); + match = await doGeocode(fallbackQuery) + } + } + + if (match) { + console.log(`[geocodePlace] Best match for "${query}": ${match.name} (${match.latitude}, ${match.longitude})`); + } else { + console.log(`[geocodePlace] No results found for "${query}"`); + } + + return match +} + + export async function fetchTidesForPlace(query: string): Promise { const place = await geocodePlace(query) if (!place) { diff --git a/server/src/utils/tideProvider.test.ts b/server/src/utils/tideProvider.test.ts index f7bb645..e09bff8 100644 --- a/server/src/utils/tideProvider.test.ts +++ b/server/src/utils/tideProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import * as bshTides from './bshTides.js' import * as openMeteoTides from './openMeteoTides.js' -import { fetchTidesForCoordinates } from './tideProvider.js' +import { fetchTidesForCoordinates, fetchTidesForPlace } from './tideProvider.js' describe('fetchTidesForCoordinates', () => { beforeEach(() => { @@ -73,3 +73,48 @@ describe('fetchTidesForCoordinates', () => { expect(result.tides.data.source).toContain('Fallback') }) }) + +describe('fetchTidesForPlace', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('matches BSH station directly by name startsWith', async () => { + vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([ + { id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 } + ]) + const fetchSpy = vi.spyOn(bshTides, 'fetchBshTidesForStation').mockResolvedValue({ + distanceKm: 0, + location: { name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85, source: 'bsh_station', stationId: 'buesum_schleuse' }, + tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } } + }) + + const result = await fetchTidesForPlace('Buesum') + expect(fetchSpy).toHaveBeenCalledWith('buesum_schleuse', undefined) + expect(result.location.name).toBe('Büsum, Schleuse') + }) + + it('falls back to geocoding if BSH station index does not match', async () => { + vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([ + { id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 } + ]) + vi.spyOn(openMeteoTides, 'geocodePlace').mockResolvedValue({ + name: 'Kiel', + latitude: 54.32, + longitude: 10.13 + }) + const coordSpy = vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({ + distanceKm: 0, + location: { name: 'Kiel-Holtenau', lat: 54.37, lon: 10.15, source: 'bsh_station', stationId: 'kiel_holtenau' }, + tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } } + }) + + const result = await fetchTidesForPlace('Kiel') + expect(coordSpy).toHaveBeenCalledWith(54.32, 10.13) + expect(result.location.name).toBe('Kiel-Holtenau') + }) +}) diff --git a/server/src/utils/tideProvider.ts b/server/src/utils/tideProvider.ts index 234d5e2..e0bbe65 100644 --- a/server/src/utils/tideProvider.ts +++ b/server/src/utils/tideProvider.ts @@ -2,6 +2,7 @@ import { fetchBshTidesForCoordinates, fetchBshTidesForStation, listNearbyBshStations, + loadBshStationIndex, MAX_BSH_DISTANCE_KM, type BshStationSuggestion } from './bshTides.js' @@ -77,9 +78,52 @@ export async function fetchTidesForStation( } } +function normalizeForMatching(s: string): string { + return s + .toLowerCase() + .trim() + .replace(/ae/g, 'ä') + .replace(/oe/g, 'ö') + .replace(/ue/g, 'ü') + .replace(/ss/g, 'ß'); +} + export async function fetchTidesForPlace(query: string): Promise { + const normQuery = normalizeForMatching(query) + if (normQuery) { + try { + const stations = await loadBshStationIndex() + let match = stations.find(s => normalizeForMatching(s.name) === normQuery) + if (!match) { + match = stations.find(s => normalizeForMatching(s.name).startsWith(normQuery)) + } + if (match) { + console.log(`[tideProvider] Match found in BSH station index for "${query}": ${match.name} (${match.id})`) + return await fetchTidesForStation(match.id) + } + } catch (err) { + console.warn('[tideProvider] Direct BSH station lookup failed:', err) + } + } + const place = await geocodePlace(query) if (!place) { + if (normQuery) { + try { + const stations = await loadBshStationIndex() + const match = stations.find(s => + normalizeForMatching(s.name).includes(normQuery) || + normQuery.includes(normalizeForMatching(s.name)) + ) + if (match) { + console.log(`[tideProvider] Geocoding failed. Found fallback BSH station match for "${query}": ${match.name} (${match.id})`) + return await fetchTidesForStation(match.id) + } + } catch (err) { + console.warn('[tideProvider] Fallback BSH station lookup failed:', err) + } + } + const err = new Error('place_not_found') as Error & { status?: number } err.status = 404 throw err