fix(tides): fix stale locations in frontend and implement digraph fallback & direct BSH matching on server

This commit is contained in:
2026-06-12 12:09:31 +02:00
parent 4f519e34b4
commit 0e0f045e84
5 changed files with 162 additions and 41 deletions
+12 -17
View File
@@ -68,7 +68,8 @@ import {
import { import {
fetchTidesForEntry, fetchTidesForEntry,
fetchTidesForStationChoice, fetchTidesForStationChoice,
type TideFetchNeedsStationPick type TideFetchNeedsStationPick,
type TideFetchResult
} from '../utils/tideFetch.js' } from '../utils/tideFetch.js'
import { import {
geolocationErrorI18nKey, geolocationErrorI18nKey,
@@ -851,17 +852,10 @@ export default function LiveLogView({
setError(null) setError(null)
void (async () => { void (async () => {
try { 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({ const location = resolveTideFetchLocation({
events: eventsForLocation, events,
entryDate: entryDateForLocation, entryDate: date,
departure: departureForLocation departure
}) })
if ('error' in location) { if ('error' in location) {
void showAlert( void showAlert(
@@ -875,19 +869,20 @@ export default function LiveLogView({
const outcome = await fetchTidesForEntry({ const outcome = await fetchTidesForEntry({
fetchLocation: location, fetchLocation: location,
entryDate: entryDateForLocation, entryDate: date,
analyticsSource: 'live_log' analyticsSource: 'live_log'
}) })
if (outcome.kind === 'pick_station') { if ('kind' in outcome && outcome.kind === 'pick_station') {
setTideStationPicker(outcome) setTideStationPicker(outcome as TideFetchNeedsStationPick)
return return
} }
const result = outcome as TideFetchResult
setTidePreview({ setTidePreview({
highWater: outcome.highWater, highWater: result.highWater,
lowWater: outcome.lowWater, lowWater: result.lowWater,
location: outcome.location location: result.location
}) })
setModal('tides') setModal('tides')
} catch (err) { } catch (err) {
+10 -16
View File
@@ -54,7 +54,8 @@ import {
import { import {
fetchTidesForEntry, fetchTidesForEntry,
fetchTidesForStationChoice, fetchTidesForStationChoice,
type TideFetchNeedsStationPick type TideFetchNeedsStationPick,
type TideFetchResult
} from '../utils/tideFetch.js' } from '../utils/tideFetch.js'
import { import {
buildTravelDayContext, buildTravelDayContext,
@@ -62,7 +63,7 @@ import {
generateTravelDaySummary, generateTravelDaySummary,
TravelDaySummaryApiError TravelDaySummaryApiError
} from '../services/aiSummary.js' } from '../services/aiSummary.js'
import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { getAiAuthorized } from '../services/userPreferences.js' import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
getDecryptedTrack, getDecryptedTrack,
@@ -1351,17 +1352,10 @@ export default function LogEntryEditor({
setTidesLoading(true) setTidesLoading(true)
try { 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({ const location = resolveTideFetchLocation({
events: eventsForLocation, events,
entryDate: entryDateForLocation, entryDate: date,
departure: departureForLocation departure
}) })
if ('error' in location) { if ('error' in location) {
if (location.error === 'stale') { if (location.error === 'stale') {
@@ -1374,16 +1368,16 @@ export default function LogEntryEditor({
const outcome = await fetchTidesForEntry({ const outcome = await fetchTidesForEntry({
fetchLocation: location, fetchLocation: location,
entryDate: entryDateForLocation, entryDate: date,
analyticsSource: 'entry_editor' analyticsSource: 'entry_editor'
}) })
if (outcome.kind === 'pick_station') { if ('kind' in outcome && outcome.kind === 'pick_station') {
setTideStationPicker(outcome) setTideStationPicker(outcome as TideFetchNeedsStationPick)
return return
} }
applyTideFetchResult(outcome) applyTideFetchResult(outcome as TideFetchResult)
} catch (err) { } catch (err) {
if (err instanceof TidesApiError) { if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') { if (err.code === 'OFFLINE') {
+50 -7
View File
@@ -224,19 +224,62 @@ function scoreGeocodingResult(query: string, result: GeocodingResult): number {
return score return score
} }
export async function geocodePlace(query: string): Promise<GeocodingResult | null> { 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<GeocodingResult | null> {
const url = new URL(GEOCODING_API) 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('count', '10')
url.searchParams.set('language', 'de') url.searchParams.set('language', 'de')
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString()) console.log(`[geocodePlace] Fetching URL: ${url.toString()}`);
const results = data.results ?? [] try {
if (results.length === 0) return null const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
const results = data.results ?? []
return [...results].sort((a, b) => scoreGeocodingResult(query, b) - scoreGeocodingResult(query, a))[0] 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<GeocodingResult | null> {
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<TideLookupResult> { export async function fetchTidesForPlace(query: string): Promise<TideLookupResult> {
const place = await geocodePlace(query) const place = await geocodePlace(query)
if (!place) { if (!place) {
+46 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as bshTides from './bshTides.js' import * as bshTides from './bshTides.js'
import * as openMeteoTides from './openMeteoTides.js' import * as openMeteoTides from './openMeteoTides.js'
import { fetchTidesForCoordinates } from './tideProvider.js' import { fetchTidesForCoordinates, fetchTidesForPlace } from './tideProvider.js'
describe('fetchTidesForCoordinates', () => { describe('fetchTidesForCoordinates', () => {
beforeEach(() => { beforeEach(() => {
@@ -73,3 +73,48 @@ describe('fetchTidesForCoordinates', () => {
expect(result.tides.data.source).toContain('Fallback') 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')
})
})
+44
View File
@@ -2,6 +2,7 @@ import {
fetchBshTidesForCoordinates, fetchBshTidesForCoordinates,
fetchBshTidesForStation, fetchBshTidesForStation,
listNearbyBshStations, listNearbyBshStations,
loadBshStationIndex,
MAX_BSH_DISTANCE_KM, MAX_BSH_DISTANCE_KM,
type BshStationSuggestion type BshStationSuggestion
} from './bshTides.js' } 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<TideProviderResult> { export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
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) const place = await geocodePlace(query)
if (!place) { 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 } const err = new Error('place_not_found') as Error & { status?: number }
err.status = 404 err.status = 404
throw err throw err