fix(tides): fix stale locations in frontend and implement digraph fallback & direct BSH matching on server
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
console.log(`[geocodePlace] Fetching URL: ${url.toString()}`);
|
||||||
|
try {
|
||||||
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
|
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
|
||||||
const results = data.results ?? []
|
const results = data.results ?? []
|
||||||
if (results.length === 0) return null
|
if (results.length === 0) {
|
||||||
|
return null
|
||||||
return [...results].sort((a, b) => scoreGeocodingResult(query, b) - scoreGeocodingResult(query, a))[0]
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user