diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4c8e9d924..ed5998743 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1119,7 +1119,8 @@ export const healthApi = { export const weatherApi = { // `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614). - get: (lat: number, lng: number, date: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + // `lang` localizes the description; when omitted the server keeps its own default (#2167). + get: (lat: number, lng: number, date: string, lang?: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, lang, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), getCurrent: (lat: number, lng: number, lang?: string): Promise => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index af74df7f3..a73f35ad5 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -56,9 +56,11 @@ interface DayDetailPanelProps { mobile?: boolean /** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */ onUpdateDayTitle?: (dayId: number, title: string) => void + /** Name of the place the day's weather is anchored to — captioned above the forecast (#2167). */ + weatherPlaceName?: string | null } -export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) { +export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle, weatherPlaceName = null }: DayDetailPanelProps) { const { t, language, locale } = useTranslation() const can = useCanDo() const tripObj = useTripStore((s) => s.trip) @@ -211,6 +213,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri ) : weather ? (
+ {/* Which place the forecast is for — on a roadtrip "the day's weather" + is ambiguous without it (#2167). */} + {weatherPlaceName && ( +
+ {t('day.weatherFor', { name: weatherPlaceName })} +
+ )} {/* Summary row */}
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 61d7d5594..abd650b64 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -87,7 +87,9 @@ vi.mock('../../hooks/useDayNotes', () => ({ })) vi.mock('../Weather/WeatherWidget', () => ({ - default: () => , + default: (props: { locationName?: string | null }) => ( + + ), })) // A stable toast object so tests can assert on the messages the sidebar raises. @@ -2896,11 +2898,34 @@ describe('DayPlanSidebar', () => { expect(onReorder).not.toHaveBeenCalled() }) - it('FE-PLANNER-DAYPLAN-130: the weather badge falls back to any located trip place', () => { + // #2167 — the badge used to borrow ANY located trip place, so a roadtrip day + // without stops silently showed another city's weather. Day-local only now. + it('FE-PLANNER-DAYPLAN-130: no weather badge for a day without located stops or a bookend hotel (#2167)', () => { const located = buildPlace({ id: 5, name: 'Somewhere', lat: 48.85, lng: 2.35 }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) render() - expect(screen.getByTestId('weather-widget')).toBeInTheDocument() + expect(screen.queryByTestId('weather-widget')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-130b: the weather badge anchors to the day bookend hotel, independent of the optimize setting (#2167)', () => { + // The hotel fallback is unconditional (matching the mobile timeline) — turn the + // route-optimization setting OFF to prove the weather anchor ignores it. + seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', optimize_from_accommodation: false } } as any) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }) + const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_name: 'Hotel Roma', place_lat: 41.9, place_lng: 12.5 }] + render() + const widgets = screen.getAllByTestId('weather-widget') + expect(widgets.length).toBeGreaterThan(0) + expect(widgets[0]).toHaveAttribute('data-location', 'Hotel Roma') + }) + + it('FE-PLANNER-DAYPLAN-130c: the weather badge names the day-local stop it is anchored to (#2167)', () => { + const place = buildPlace({ id: 5, name: 'Louvre', lat: 48.86, lng: 2.34 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) + render() + expect(screen.getByTestId('weather-widget')).toHaveAttribute('data-location', 'Louvre') }) it('FE-PLANNER-DAYPLAN-131: a read-only trip drops the edit affordances from day and note rows', () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 836c938e1..e6cd725a7 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1043,10 +1043,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { return formatMoneySum(entries, costBase, locale, fxRates) }, [days, assignments, currency, costBase, locale, fxRates]) - // Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort - const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng) - const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng) - return { tripId, trip, @@ -1182,8 +1178,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { handleOptimize, handleDropOnDay, totalCostLabel, - anyGeoAssignment, - anyGeoPlace, expandedRouteDayIds, setExpandedRouteDayIds, } @@ -1355,8 +1349,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP handleOptimize, handleDropOnDay, totalCostLabel, - anyGeoAssignment, - anyGeoPlace, expandedRouteDayIds, setExpandedRouteDayIds, } = S @@ -1554,7 +1546,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // routable when accommodation optimization can bookend it with a hotel // (hotel → place → hotel, the same line the map draws) — otherwise the tools // vanish on such a day (#1330). Purely additive to the 2+ case. - const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null + // The weather anchor below must not depend on the optimize-from-accommodation + // setting — where you wake up is a fact about the day, not a routing choice — + // so the bookend lookup runs unconditionally (mirrors useMPlanTimeline) while + // the route tools keep honoring the setting via routeBookends. + const dayBookends = getDayBookendHotels(day, days, accommodations) + const routeBookends = optimizeFromAccommodation !== false ? dayBookends : null const hasRouteBookend = !!( (routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) || (routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null) @@ -1668,12 +1665,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP > {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} {(() => { - // anyGeoPlace is an assignment (has .place) or a bare place — read coords from either. - const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined - const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined - const wLat = loc?.place?.lat ?? geoLat - const wLng = loc?.place?.lng ?? geoLng - const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null) + // Day-local anchor only (#2167): the day's first located stop, else the + // hotel you wake up in. No trip-wide fallback — on a roadtrip that + // silently showed another city's weather with nothing naming the place. + const weatherHotel = loc == null ? dayBookends.morning : undefined + const wLat = loc?.place?.lat ?? weatherHotel?.place_lat + const wLng = loc?.place?.lng ?? weatherHotel?.place_lng + const weatherName = loc?.place?.name ?? weatherHotel?.place_name ?? null + const hasWeather = !!(day.date && wLat != null && wLng != null) return (
- +
)} diff --git a/client/src/components/Weather/WeatherWidget.test.tsx b/client/src/components/Weather/WeatherWidget.test.tsx index 9c3abd7f5..db6811c64 100644 --- a/client/src/components/Weather/WeatherWidget.test.tsx +++ b/client/src/components/Weather/WeatherWidget.test.tsx @@ -113,9 +113,9 @@ describe('WeatherWidget', () => { }) }) - it('FE-COMP-WEATHERWIDGET-010: uses cached data from sessionStorage', async () => { + it('FE-COMP-WEATHERWIDGET-010: uses fresh cached data from sessionStorage without refetching', async () => { const cached = buildWeather({ temp: 20 }) - sessionStorage.setItem('weather_48.86_2.35_2025-06-01', JSON.stringify(cached)) + sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: cached, fetchedAt: Date.now() })) useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } }) render() await waitFor(() => { @@ -127,7 +127,7 @@ describe('WeatherWidget', () => { it('FE-COMP-WEATHERWIDGET-011: re-fetches in background for cached climate data', async () => { const climateData = buildWeather({ temp: 15, main: 'Clouds', type: 'climate', description: 'cloudy' }) const forecastData = buildWeather({ temp: 22, main: 'Clear', type: 'forecast', description: 'clear sky' }) - sessionStorage.setItem('weather_48.86_2.35_2025-06-01', JSON.stringify(climateData)) + sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: climateData, fetchedAt: Date.now() })) vi.mocked(weatherApi.get).mockResolvedValue(forecastData) useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } }) @@ -144,4 +144,68 @@ describe('WeatherWidget', () => { }) expect(screen.queryByText(/Ø/)).not.toBeInTheDocument() }) + + // #2167 — a standalone PWA keeps sessionStorage alive across days, so a + // forecast fetched before the trip was still shown unchanged on the day itself. + it('FE-COMP-WEATHERWIDGET-012: revalidates a forecast cache entry older than the TTL', async () => { + const staleData = buildWeather({ temp: 20 }) + const freshData = buildWeather({ temp: 22 }) + sessionStorage.setItem( + 'weather_48.86_2.35_2025-06-01_en', + JSON.stringify({ data: staleData, fetchedAt: Date.now() - 2 * 60 * 60 * 1000 }), + ) + vi.mocked(weatherApi.get).mockResolvedValue(freshData) + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } }) + + render() + + // Serve-then-revalidate: the stale value renders immediately … + await waitFor(() => { + expect(screen.getByText(/20°C|22°C/)).toBeInTheDocument() + }) + // … and the background refresh replaces it and the cache entry. + await waitFor(() => { + expect(screen.getByText('22°C')).toBeInTheDocument() + }) + expect(weatherApi.get).toHaveBeenCalledTimes(1) + const stored = JSON.parse(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_en')!) + expect(stored.data.temp).toBe(22) + expect(typeof stored.fetchedAt).toBe('number') + }) + + it('FE-COMP-WEATHERWIDGET-013: treats a pre-TTL raw cache entry as stale and refreshes it', async () => { + // Live PWA sessions still hold entries in the old bare-result format. + sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify(buildWeather({ temp: 18 }))) + vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 25 })) + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } }) + + render() + + await waitFor(() => { + expect(screen.getByText('25°C')).toBeInTheDocument() + }) + expect(weatherApi.get).toHaveBeenCalledTimes(1) + }) + + // #2167 (mrwulf) — the widget must be able to say WHERE the forecast is for. + it('FE-COMP-WEATHERWIDGET-014: renders locationName as a tooltip title', async () => { + vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 })) + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } }) + render() + await waitFor(() => { + expect(screen.getByText('20°C')).toBeInTheDocument() + }) + expect(screen.getByTitle('Rome')).toBeInTheDocument() + }) + + it('FE-COMP-WEATHERWIDGET-015: sends the user language and keys the cache by it', async () => { + vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 })) + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius', language: 'de' } }) + render() + await waitFor(() => { + expect(screen.getByText('20°C')).toBeInTheDocument() + }) + expect(weatherApi.get).toHaveBeenCalledWith(48.86, 2.35, '2025-06-01', 'de') + expect(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_de')).not.toBeNull() + }) }) diff --git a/client/src/components/Weather/WeatherWidget.tsx b/client/src/components/Weather/WeatherWidget.tsx index 5ef3d6e4c..d56753d68 100644 --- a/client/src/components/Weather/WeatherWidget.tsx +++ b/client/src/components/Weather/WeatherWidget.tsx @@ -37,6 +37,11 @@ function setWeatherCache(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {} } +// Matches the server's own forecast TTL. In a standalone PWA sessionStorage lives +// as long as the app process, so without a TTL a forecast fetched days before the +// trip day was still shown unchanged on the day itself (#2167). +const WEATHER_TTL_MS = 60 * 60 * 1000 + interface WeatherWidgetProps { lat: number | null lng: number | null @@ -44,53 +49,70 @@ interface WeatherWidgetProps { compact?: boolean /** Vertical icon-over-temp layout that inherits its color (for the day badge). */ stacked?: boolean + /** Name of the place the forecast is anchored to — surfaces as a tooltip (#2167). */ + locationName?: string | null } -export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) { +export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false, locationName = null }: WeatherWidgetProps) { const [weather, setWeather] = useState(null) const [loading, setLoading] = useState(false) const [failed, setFailed] = useState(false) const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' + const language = useSettingsStore(s => s.settings.language) || 'en' useEffect(() => { if (!lat || !lng || !date) return + let cancelled = false const rLat = Math.round(lat * 100) / 100 const rLng = Math.round(lng * 100) / 100 - const cacheKey = `weather_${rLat}_${rLng}_${date}` - const cached = getWeatherCache(cacheKey) - if (cached !== undefined) { - if (cached === null) setFailed(true) - // Climate data: use from cache but re-fetch in background to upgrade to forecast - else if (cached.type === 'climate') { - setWeather(cached) - fetchWeather(lat, lng, date) - .then(data => { - if (!data.error && data.temp !== undefined && data.type === 'forecast') { - setWeatherCache(cacheKey, data) - setWeather(data) - } - }) - .catch(() => {}) - return - } else { - setWeather(cached) - return - } - return - } - setLoading(true) - fetchWeather(lat, lng, date) - .then(data => { - if (data.error || data.temp === undefined) { - setFailed(true) - } else { - setWeatherCache(cacheKey, data) + // The language is part of the key: descriptions come back localized, and the + // server caches per language too — a lang-less key would re-mix them here. + const cacheKey = `weather_${rLat}_${rLng}_${date}_${language}` + + const revalidate = (acceptOnlyForecast) => { + fetchWeather(lat, lng, date, language) + .then(data => { + if (cancelled || data.error || data.temp === undefined) return + if (acceptOnlyForecast && data.type !== 'forecast') return + setWeatherCache(cacheKey, { data, fetchedAt: Date.now() }) setWeather(data) - } - }) - .catch(() => { setFailed(true) }) - .finally(() => setLoading(false)) - }, [lat, lng, date]) + }) + .catch(() => {}) + } + + const cached = getWeatherCache(cacheKey) + if (cached !== undefined && cached !== null) { + // Entries written before the TTL landed are the bare result — treat as stale. + const entry = cached.data !== undefined && typeof cached.fetchedAt === 'number' + ? cached + : { data: cached, fetchedAt: 0 } + setWeather(entry.data) + if (entry.data?.type === 'climate') { + // Climate data: shown from cache, but re-fetched to upgrade to a forecast. + revalidate(true) + } else if (Date.now() - entry.fetchedAt > WEATHER_TTL_MS) { + // Stale forecast: serve it, then refresh in the background. + revalidate(false) + } + } else if (cached === null) { + setFailed(true) + } else { + setLoading(true) + fetchWeather(lat, lng, date, language) + .then(data => { + if (cancelled) return + if (data.error || data.temp === undefined) { + setFailed(true) + } else { + setWeatherCache(cacheKey, { data, fetchedAt: Date.now() }) + setWeather(data) + } + }) + .catch(() => { if (!cancelled) setFailed(true) }) + .finally(() => { if (!cancelled) setLoading(false) }) + } + return () => { cancelled = true } + }, [lat, lng, date, language]) if (!lat || !lng) return null @@ -113,9 +135,13 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked const unit = isFahrenheit ? '°F' : '°C' const isClimate = weather.type === 'climate' + // The forecast's anchor place, as a tooltip on every variant — no layout change, + // but the widget finally says WHERE the weather is for (#2167). + const title = locationName || undefined + if (stacked) { return ( -
+
{temp !== null && {isClimate ? 'Ø' : ''}{temp}°}
@@ -124,7 +150,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked if (compact) { return ( - + {temp !== null && {isClimate ? 'Ø ' : ''}{temp}{unit}} @@ -132,7 +158,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked } return ( -
+
{temp !== null && {isClimate ? 'Ø ' : ''}{temp}{unit}} {weather.description && {weather.description}} diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index df8205c05..e9bddb50e 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -157,7 +157,7 @@ export default function MJourneyEntrySheet({ const [placeResult, weatherResult] = await Promise.allSettled([ mapsApi.reverse(pos.lat, pos.lng, language), - weatherApi.getCurrent(pos.lat, pos.lng, 'en'), + weatherApi.getCurrent(pos.lat, pos.lng, language), ]) if (!active) return if (placeResult.status === 'fulfilled') { diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index e513cf900..c096e3045 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -153,6 +153,11 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) { // the pill drops to icon-only there rather than saying it twice. dayLabel={editing ? '' : dayLabel} openLabel={t('day.overview')} + // The chip still opens the day sheet, but its accessible name says + // WHERE the forecast is for — a roadtrip day is ambiguous otherwise (#2167). + weatherLabel={tl.weatherPlaceName + ? `${t('day.overview')} · ${t('day.weatherFor', { name: tl.weatherPlaceName })}` + : undefined} onOpenDay={() => shell.openSheet('day', { dayId: day.id })} /> )} @@ -394,10 +399,12 @@ function EditHeader({ tl, planner, shell }: { * carried this, which left a day without a stay — and, with no weather either, * the whole header — with no way into the day sheet at all (#2004). */ -function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: { +function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay }: { tl: MPlanTimelineController dayLabel: string openLabel: string + /** Weather-chip label naming the forecast's anchor place (#2167); falls back to openLabel. */ + weatherLabel?: string onOpenDay: () => void }) { const WeatherIcon = weatherIconFor(tl.weather?.main) @@ -434,7 +441,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
+ {/* Which place the forecast is for — on a roadtrip "the day's + weather" is ambiguous without it (#2167). */} + {weatherPlaceName && ( +
+ {t('day.weatherFor', { name: weatherPlaceName })} +
+ )} {weather.type === 'climate' && (
{t('day.climateHint')}
)} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7ea1b7f00..15c2629e2 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -28,6 +28,7 @@ import type { BudgetItem } from '../types' import PluginFrame from '../components/Plugins/PluginFrame' import ErrorBoundary from '../components/shared/ErrorBoundary' import { lazyWithRetry } from '../utils/lazyWithRetry' +import { getDayBookendHotels } from '../utils/dayOrder' import TripWarningsBanner from '../components/Planner/TripWarningsBanner' import Navbar from '../components/Layout/Navbar' import { useToast } from '../components/shared/Toast' @@ -591,7 +592,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {showDayDetail && !selectedPlace && (() => { const currentDay = days.find(d => d.id === showDayDetail.id) || showDayDetail const dayAssignments = assignments[String(currentDay.id)] || [] - const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place || places.find(p => p.lat && p.lng) + // Day-local weather anchor only (#2167): first located stop of THIS day, + // else the hotel you wake up in (unconditional bookend lookup, mirroring + // useMPlanTimeline) — never a place from another day. + const locatedPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place + const weatherHotel = locatedPlace ? undefined : getDayBookendHotels(currentDay, days, tripAccommodations).morning + const weatherLat = locatedPlace?.lat ?? weatherHotel?.place_lat ?? null + const weatherLng = locatedPlace?.lng ?? weatherHotel?.place_lng ?? null + const weatherPlaceName = locatedPlace?.name ?? weatherHotel?.place_name ?? null return ( { setShowDayDetail(null); handleSelectDay(null) }} onAccommodationChange={loadAccommodations} leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)} diff --git a/client/src/services/weatherQueue.test.ts b/client/src/services/weatherQueue.test.ts index 9187102e8..1d9817f16 100644 --- a/client/src/services/weatherQueue.test.ts +++ b/client/src/services/weatherQueue.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { WeatherResult } from '@trek/shared' -const get = vi.fn(async (_lat: number, _lng: number, _date: string) => ({} as WeatherResult)) +const get = vi.fn(async (_lat: number, _lng: number, _date: string, _lang?: string) => ({} as WeatherResult)) -vi.mock('../api/client', () => ({ weatherApi: { get: (lat: number, lng: number, date: string) => get(lat, lng, date) } })) +vi.mock('../api/client', () => ({ weatherApi: { get: (lat: number, lng: number, date: string, lang?: string) => get(lat, lng, date, lang) } })) import { fetchWeather } from './weatherQueue' @@ -25,7 +25,17 @@ describe('fetchWeather', () => { get.mockResolvedValue(result) await expect(fetchWeather(48.1, 11.5, '2026-06-15')).resolves.toBe(result) - expect(get).toHaveBeenCalledWith(48.1, 11.5, '2026-06-15') + expect(get).toHaveBeenCalledWith(48.1, 11.5, '2026-06-15', undefined) + }) + + // #2167 — the widget path is the only weather caller that never touched + // weatherApi directly, so the language has to travel through the queue. + it('FE-W4WQ-006: forwards the language when one is given', async () => { + const result = { temp: 21 } as unknown as WeatherResult + get.mockResolvedValue(result) + + await expect(fetchWeather(48.1, 11.5, '2026-06-15', 'ja')).resolves.toBe(result) + expect(get).toHaveBeenCalledWith(48.1, 11.5, '2026-06-15', 'ja') }) it('FE-W4WQ-002: lets at most three requests run at once', async () => { diff --git a/client/src/services/weatherQueue.ts b/client/src/services/weatherQueue.ts index 56b034db6..3234bc5ba 100644 --- a/client/src/services/weatherQueue.ts +++ b/client/src/services/weatherQueue.ts @@ -15,10 +15,10 @@ function release(): void { else active-- } -export async function fetchWeather(lat: number, lng: number, date: string) { +export async function fetchWeather(lat: number, lng: number, date: string, lang?: string) { await acquire() try { - return await weatherApi.get(lat, lng, date) + return await weatherApi.get(lat, lng, date, lang) } finally { release() } diff --git a/client/tests/unit/mobile/trip/MDaySheet.test.tsx b/client/tests/unit/mobile/trip/MDaySheet.test.tsx index 32322c9ce..9340d451c 100644 --- a/client/tests/unit/mobile/trip/MDaySheet.test.tsx +++ b/client/tests/unit/mobile/trip/MDaySheet.test.tsx @@ -163,6 +163,8 @@ describe('MDaySheet', () => { expect(screen.getByText('20°')).toBeInTheDocument() expect(screen.getByRole('dialog').textContent).toContain('24° / 12° ·') expect(screen.getByText('light rain')).toBeInTheDocument() + // #2167 (mrwulf) — the block names the place the forecast is anchored to. + expect(screen.getByText('Forecast for Museum')).toBeInTheDocument() }) it('FE-MOB-DAYSH-005: converts to Fahrenheit when the user asked for it', async () => { @@ -193,7 +195,8 @@ describe('MDaySheet', () => { }) it('FE-MOB-DAYSH-009: skips the weather block entirely without coordinates', async () => { - await renderSheet(makePlanner({ assignments: {}, places: [] })) + // No located stop AND no stay — with a stay the hotel would anchor the weather (#2167). + await renderSheet(makePlanner({ assignments: {}, places: [], tripAccommodations: [] })) expect(weatherApi.getDetailed).not.toHaveBeenCalled() expect(screen.queryByText('No weather data available. Add a place with coordinates.')).not.toBeInTheDocument() }) @@ -374,13 +377,26 @@ describe('MDaySheet', () => { expect(screen.getByText('Stay')).toBeInTheDocument() }) - it('FE-MOB-DAYSH-026: falls back to any trip place with coordinates as the weather anchor', async () => { + // #2167 — the anchor is day-local: never a place from another day, even when + // the trip pool has located places. The day's stay is the fallback instead. + it('FE-MOB-DAYSH-026: anchors the weather to the day bookend hotel, not some trip place', async () => { await renderSheet(makePlanner({ assignments: {}, places: [{ id: 200, name: 'Prater', lat: 48.31, lng: 16.41 }], })) - expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.31, 16.41, '2026-05-02', 'en') + expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.2, 16.35, '2026-05-02', 'en') expect(screen.getByText('20°')).toBeInTheDocument() + expect(screen.getByText('Forecast for Hotel Sacher')).toBeInTheDocument() + }) + + it('FE-MOB-DAYSH-026b: shows no weather at all without a day-local anchor (#2167)', async () => { + await renderSheet(makePlanner({ + assignments: {}, + places: [{ id: 200, name: 'Prater', lat: 48.31, lng: 16.41 }], + tripAccommodations: [], + })) + expect(weatherApi.getDetailed).not.toHaveBeenCalled() + expect(screen.queryByText('20°')).not.toBeInTheDocument() }) it('FE-MOB-DAYSH-027: hides every editing affordance for a read-only member', async () => { diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx index 6cf3118da..3f7d6ec37 100644 --- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx @@ -95,6 +95,7 @@ function buildTl(over: Record = {}): MPlanTimelineController { hotelChips: [], weather: null, weatherTemp: null, + weatherPlaceName: null, upNext: { assignment: MUSEUM, minutesUntil: 45 }, language: 'en', timeFormat: '24h', @@ -198,6 +199,18 @@ describe('MPlanTimeline', () => { expect(screen.getByText('17°')).toBeInTheDocument() }) + it('FE-MOB-PLTL-005b: the weather chip names its anchor place in the accessible label (#2167)', () => { + renderTimeline({ + weather: { main: 'Rain', temp: 17 }, + weatherTemp: 17, + weatherPlaceName: 'Shibuya', + }) + + // echoT renders t('day.weatherFor', { name }) as 'day.weatherFor:Shibuya'. + expect(screen.getByRole('button', { name: 'day.overview · day.weatherFor:Shibuya' })).toBeInTheDocument() + expect(screen.getByTitle('Shibuya')).toBeInTheDocument() + }) + it('FE-MOB-PLTL-006: a chip opens the day sheet', () => { const { shell } = renderTimeline({ hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null }], diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts index 3e5acaa8d..51e44a79a 100644 --- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts @@ -172,8 +172,10 @@ describe('useMPlanTimeline', () => { it('FE-MOB-PLTL-007: requests the forecast for the first located stop', async () => { const { result } = await renderTimeline(makePlanner()) await waitFor(() => expect(result.current.weather).not.toBeNull()) - expect(weatherApi.get).toHaveBeenCalledWith(48, 16.1, '2026-05-02') + expect(weatherApi.get).toHaveBeenCalledWith(48, 16.1, '2026-05-02', 'en') expect(result.current.weatherTemp).toBe(20) + // #2167 — the anchor's name rides along so the chip can say where this is for. + expect(result.current.weatherPlaceName).toBe('Museum') }) it('FE-MOB-PLTL-008: converts the forecast to Fahrenheit on request', async () => { @@ -200,8 +202,9 @@ describe('useMPlanTimeline', () => { id: 14, day_id: 2, order_index: 0, place_id: 104, place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }), }) - await renderTimeline(makePlanner({ assignments: { '2': [vague] }, tripAccommodations: [HOTEL] })) - expect(weatherApi.get).toHaveBeenCalledWith(48, 16.05, '2026-05-02') + const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] }, tripAccommodations: [HOTEL] })) + expect(weatherApi.get).toHaveBeenCalledWith(48, 16.05, '2026-05-02', 'en') + expect(result.current.weatherPlaceName).toBe('Hotel Sacher') }) it('FE-MOB-PLTL-012: skips the forecast without any coordinates at all', async () => { @@ -209,8 +212,9 @@ describe('useMPlanTimeline', () => { id: 14, day_id: 2, order_index: 0, place_id: 104, place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }), }) - await renderTimeline(makePlanner({ assignments: { '2': [vague] } })) + const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] } })) expect(weatherApi.get).not.toHaveBeenCalled() + expect(result.current.weatherPlaceName).toBeNull() }) it('FE-MOB-PLTL-045: drops a forecast that settles after the timeline is gone', async () => { diff --git a/server/src/nest/weather/weather.impl.ts b/server/src/nest/weather/weather.impl.ts index 2ba7a86a8..8a6dfb751 100644 --- a/server/src/nest/weather/weather.impl.ts +++ b/server/src/nest/weather/weather.impl.ts @@ -198,10 +198,15 @@ const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours -export function cacheKey(lat: string, lng: string, date?: string): string { +export function cacheKey(lat: string, lng: string, date?: string, lang?: string): string { const rlat = Number.parseFloat(lat).toFixed(2); const rlng = Number.parseFloat(lng).toFixed(2); - return `${rlat}_${rlng}_${date || 'current'}`; + const base = `${rlat}_${rlng}_${date || 'current'}`; + // Descriptions come back localized, so the language is part of the identity — + // without it a cached German answer serves English callers for up to the TTL + // (and the MCP tools default to 'en' while REST defaults to 'de', so the two + // surfaces poisoned each other's descriptions). + return lang ? `${base}_${lang}` : base; } function getCached(key: string): WeatherResult | null { @@ -227,6 +232,46 @@ export function estimateCondition(tempAvg: number, precipMm: number): string { return tempAvg > 15 ? 'Clear' : 'Clouds'; } +/** + * Daily figures for a recent date via the forecast API's `past_days` window. + * + * The ERA5 archive lags roughly five days behind real time, so the last days of + * an ongoing trip — and a location-local "yesterday" that has already slipped + * out of the forward window in the server's UTC frame — answered with nulls, + * rendered as a dash (#2167). The forecast model still carries those days. + * Returns null when the requested date is not in the answered series, so the + * caller can fall back to the archive. + */ +async function fetchRecentPastForecast( + lat: string, + lng: string, + dateStr: string, + lang: string, +): Promise { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&past_days=5&forecast_days=2`; + const { response, data } = await fetchOpenMeteo(url); + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); + } + + const idx = (data.daily?.time || []).indexOf(dateStr); + if (idx === -1 || data.daily!.temperature_2m_max[idx] == null || data.daily!.temperature_2m_min[idx] == null) { + return null; + } + + const code = data.daily!.weathercode[idx]; + const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; + return { + temp: Math.round((data.daily!.temperature_2m_max[idx] + data.daily!.temperature_2m_min[idx]) / 2), + temp_max: Math.round(data.daily!.temperature_2m_max[idx]), + temp_min: Math.round(data.daily!.temperature_2m_min[idx]), + main: WMO_MAP[code] || 'Clouds', + description: descriptions[code] || '', + type: 'forecast', + }; +} + // ── getWeather ────────────────────────────────────────────────────────── async function _getWeatherImpl( @@ -236,7 +281,7 @@ async function _getWeatherImpl( lang: string, time?: string, ): Promise { - const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date); + const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date, lang); if (date) { const cached = getCached(ck); @@ -274,6 +319,31 @@ async function _getWeatherImpl( setCache(ck, result, TTL_FORECAST_MS); return result; } + + // idx === -1 with diffDays <= 0: a location-local yesterday (or today in + // the hours after the UTC rollover) that the forward window no longer + // carries. Last year's climate is the wrong answer for a day that just + // happened — ask the model's past_days window before falling through. + if (diffDays <= 0) { + const recent = await fetchRecentPastForecast(lat, lng, dateStr, lang); + if (recent) { + setCache(ck, recent, TTL_FORECAST_MS); + return recent; + } + } + } + + // Recent past inside the archive's lag window: the forecast API still has + // real daily figures where ERA5 answers nulls. A request with a usable time + // stays on the archive — only it has the hourly series a journal entry + // wants (#1614). On a miss the archive below remains the fallback. + if (diffDays < -1 && diffDays >= -5 && hourIndexFromTime(time) == null) { + const dateStr = targetDate.toISOString().slice(0, 10); + const recent = await fetchRecentPastForecast(lat, lng, dateStr, lang); + if (recent) { + setCache(ck, recent, TTL_CLIMATE_MS); + return recent; + } } // Past date: use archive API for the actual date @@ -424,16 +494,16 @@ export async function getWeather( ): Promise { const lat = String(coord(rawLat, 90, 'latitude')); const lng = String(coord(rawLng, 180, 'longitude')); - const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date); + const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date, lang); const cached = getCached(ck); if (cached) return cached; - const inFlightKey = `${ck}:${lang}`; - const existing = inFlight.get(inFlightKey); + // lang is already part of ck, so the cache key doubles as the in-flight key. + const existing = inFlight.get(ck); if (existing !== undefined) return existing; const promise = _getWeatherImpl(lat, lng, date, lang, time); - inFlight.set(inFlightKey, promise); - try { return await promise; } finally { inFlight.delete(inFlightKey); } + inFlight.set(ck, promise); + try { return await promise; } finally { inFlight.delete(ck); } } // ── getDetailedWeather ────────────────────────────────────────────────── @@ -444,7 +514,7 @@ async function _getDetailedWeatherImpl( date: string, lang: string, ): Promise { - const ck = `detailed_${cacheKey(lat, lng, date)}`; + const ck = `detailed_${cacheKey(lat, lng, date, lang)}`; const cached = getCached(ck); if (cached) return cached; @@ -595,16 +665,16 @@ export async function getDetailedWeather( ): Promise { const lat = String(coord(rawLat, 90, 'latitude')); const lng = String(coord(rawLng, 180, 'longitude')); - const ck = `detailed_${cacheKey(lat, lng, date)}`; + const ck = `detailed_${cacheKey(lat, lng, date, lang)}`; const cached = getCached(ck); if (cached) return cached; - const inFlightKey = `${ck}:${lang}`; - const existing = inFlight.get(inFlightKey); + // lang is already part of ck, so the cache key doubles as the in-flight key. + const existing = inFlight.get(ck); if (existing !== undefined) return existing; const promise = _getDetailedWeatherImpl(lat, lng, date, lang); - inFlight.set(inFlightKey, promise); - try { return await promise; } finally { inFlight.delete(inFlightKey); } + inFlight.set(ck, promise); + try { return await promise; } finally { inFlight.delete(ck); } } // ── ApiError ──────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/weather/weather.impl.test.ts b/server/tests/unit/nest/weather/weather.impl.test.ts index 02a0d1dcf..295aa840f 100644 --- a/server/tests/unit/nest/weather/weather.impl.test.ts +++ b/server/tests/unit/nest/weather/weather.impl.test.ts @@ -112,6 +112,13 @@ describe('cacheKey', () => { it('preserves the date string as-is', () => { expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate'); }); + + // #2167 — descriptions are localized, so a cached German answer must not be + // served to an English caller (REST defaults 'de', the MCP tools 'en'). + it('separates languages when lang is given', () => { + expect(cacheKey('48.8566', '2.3522', '2024-06-15', 'de')).toBe('48.86_2.35_2024-06-15_de'); + expect(cacheKey('48.8566', '2.3522', '2024-06-15', 'en')).toBe('48.86_2.35_2024-06-15_en'); + }); }); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -247,6 +254,63 @@ describe('getWeather', () => { await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError); }); + // #2167 — a location-local yesterday (or today in the hours after the UTC + // rollover) can be missing from the forward series; last year's climate is + // the wrong answer for a day that just happened. + it('asks the past_days window for a local yesterday missing from the forward series', async () => { + const date = dateOffset(0); // diffDays in (-1, 0] — forecast branch + const missBody = { + daily: { + time: ['1970-01-01'], + temperature_2m_max: [10], + temperature_2m_min: [5], + weathercode: [0], + }, + }; + const pastDaysBody = { + daily: { + time: [date], + temperature_2m_max: [22], + temperature_2m_min: [12], + weathercode: [2], + }, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(missBody)) + .mockResolvedValueOnce(mockResponse(pastDaysBody)); + + const result = await getWeather('50.00', '60.00', date, 'en'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[1][0]).toContain('api.open-meteo.com/v1/forecast'); + expect(vi.mocked(fetch).mock.calls[1][0]).toContain('past_days=5'); + expect(result.type).toBe('forecast'); // NOT climate + expect(result.temp).toBe(17); + }); + + // #2167 — the lang-less cache key served whichever language was asked first. + it('keeps languages apart in the cache', async () => { + const date = dateOffset(6); + const body = { + daily: { + time: [date], + temperature_2m_max: [12], + temperature_2m_min: [8], + weathercode: [3], + }, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(body)) + .mockResolvedValueOnce(mockResponse(body)); + + const en = await getWeather('50.01', '60.01', date, 'en'); + const de = await getWeather('50.01', '60.01', date, 'de'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(en.description).toBe('Overcast'); + expect(de.description).toBe('Bewolkt'); + }); + it('falls through to climate path when date is not found in forecast data', async () => { // The forecast API returns data but NOT for our target date; the code // checks idx === -1 and falls into the diffDays > -1 climate branch. @@ -282,6 +346,85 @@ describe('getWeather', () => { }); }); + // #2167 — the ERA5 archive lags ~5 days and answered nulls for the last days + // of an ongoing trip; the forecast API's past_days window still has them. + describe('with date — recent past inside the past_days window', () => { + it('routes the date through the forecast API with past_days instead of the archive', async () => { + const date = dateOffset(-3); + const body = { + daily: { + time: [date], + temperature_2m_max: [18], + temperature_2m_min: [10], + weathercode: [2], + }, + }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); + + const result = await getWeather('51.00', '61.00', date, 'en'); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('api.open-meteo.com/v1/forecast'); + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('past_days=5'); + expect(vi.mocked(fetch).mock.calls[0][0]).not.toContain('archive-api'); + expect(result.type).toBe('forecast'); + expect(result.temp).toBe(14); + expect(result.temp_max).toBe(18); + expect(result.temp_min).toBe(10); + }); + + it('falls back to the archive when the past_days series misses the date', async () => { + const date = dateOffset(-3); + const missBody = { + daily: { + time: ['1970-01-01'], + temperature_2m_max: [1], + temperature_2m_min: [0], + weathercode: [0], + }, + }; + const archiveBody = { + daily: { + time: [date], + temperature_2m_max: [16], + temperature_2m_min: [8], + weathercode: [61], + precipitation_sum: [2], + }, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(missBody)) + .mockResolvedValueOnce(mockResponse(archiveBody)); + + const result = await getWeather('51.01', '61.01', date, 'en'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[1][0]).toContain('archive-api.open-meteo.com'); + expect(result.temp).toBe(12); + expect(result.main).toBe('Rain'); // WMO 61 — provably the archive answer + }); + + // #1614 — the hourly series only exists on the archive; a timed request + // (journal entry) must keep taking that path. + it('keeps a timed request on the archive so the hourly series stays available', async () => { + const date = dateOffset(-3); + const hourly = { temperature_2m: Array(24).fill(null), weathercode: Array(24).fill(null) }; + hourly.temperature_2m[9] = 19; + hourly.weathercode[9] = 1; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ + daily: { time: [date], temperature_2m_max: [18], temperature_2m_min: [10], weathercode: [2], precipitation_sum: [0] }, + hourly, + })); + + const result = await getWeather('51.02', '61.02', date, 'en', '09:15'); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com'); + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('hourly=temperature_2m,weathercode'); + expect(result.temp).toBe(19); + }); + }); + describe('with date — past date (diffDays < -1)', () => { it('returns forecast-type WeatherResult from the archive API', async () => { const date = dateOffset(-5); // 5 days in the past @@ -514,6 +657,35 @@ describe('getDetailedWeather', () => { expect(fetch).not.toHaveBeenCalled(); expect(second).toEqual(first); }); + + // #2167 — same lang-in-key rule as getWeather. + it('keeps languages apart in the detailed cache', async () => { + const date = dateOffset(5); + const body = { + daily: { + time: [date], + temperature_2m_max: [20], + temperature_2m_min: [10], + weathercode: [45], + precipitation_sum: [0], + precipitation_probability_max: [0], + windspeed_10m_max: [5], + sunrise: [`${date}T06:00`], + sunset: [`${date}T20:00`], + }, + hourly: { time: [], temperature_2m: [] }, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(body)) + .mockResolvedValueOnce(mockResponse(body)); + + const en = await getDetailedWeather('30.02', '40.02', date, 'en'); + const de = await getDetailedWeather('30.02', '40.02', date, 'de'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(en.description).toBe('Fog'); + expect(de.description).toBe('Nebel'); + }); }); describe('forecast path (diffDays <= 16)', () => { diff --git a/shared/src/i18n/ar/day.ts b/shared/src/i18n/ar/day.ts index 0675724c3..d5148bf64 100644 --- a/shared/src/i18n/ar/day.ts +++ b/shared/src/i18n/ar/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'التوقعات بالساعة', 'day.climateHint': 'متوسطات تاريخية — التوقعات الفعلية متاحة خلال 16 يومًا من هذا التاريخ.', 'day.noWeather': 'لا تتوفر بيانات طقس. أضف مكانًا بإحداثيات.', + 'day.weatherFor': 'التوقعات لـ {name}', 'day.overview': 'ملخص اليوم', 'day.accommodation': 'الإقامة', 'day.addAccommodation': 'إضافة إقامة', diff --git a/shared/src/i18n/br/day.ts b/shared/src/i18n/br/day.ts index 6d4f41475..97abb7052 100644 --- a/shared/src/i18n/br/day.ts +++ b/shared/src/i18n/br/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Previsão por hora', 'day.climateHint': 'Médias históricas — previsão real disponível até 16 dias desta data.', 'day.noWeather': 'Sem dados meteorológicos. Adicione um lugar com coordenadas.', + 'day.weatherFor': 'Previsão para {name}', 'day.overview': 'Resumo do dia', 'day.accommodation': 'Hospedagem', 'day.addAccommodation': 'Adicionar hospedagem', diff --git a/shared/src/i18n/ca/day.ts b/shared/src/i18n/ca/day.ts index 8e0b95174..1834810ea 100644 --- a/shared/src/i18n/ca/day.ts +++ b/shared/src/i18n/ca/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Pronòstic per hores', 'day.climateHint': 'Mitjanes històriques: el pronòstic real està disponible durant els 16 dies previs a la data.', 'day.noWeather': 'No hi ha dades meteorològiques disponibles. Afegeix un lloc amb coordenades.', + 'day.weatherFor': 'Previsió per a {name}', 'day.overview': 'Resum diari', 'day.accommodation': 'Allotjament', 'day.addAccommodation': 'Afegeix un allotjament', diff --git a/shared/src/i18n/cs/day.ts b/shared/src/i18n/cs/day.ts index 5b6f5d5f0..d1e78066f 100644 --- a/shared/src/i18n/cs/day.ts +++ b/shared/src/i18n/cs/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Hodinová předpověď', 'day.climateHint': 'Historické průměry — reálná předpověď je k dispozici do 16 dnů od tohoto data.', 'day.noWeather': 'Nejsou k dispozici žádná data o počasí. Přidejte místo se souřadnicemi.', + 'day.weatherFor': 'Předpověď pro {name}', 'day.overview': 'Denní přehled', 'day.accommodation': 'Ubytování', 'day.addAccommodation': 'Přidat ubytování', diff --git a/shared/src/i18n/de/day.ts b/shared/src/i18n/de/day.ts index 888ad4b8a..e3b5ea953 100644 --- a/shared/src/i18n/de/day.ts +++ b/shared/src/i18n/de/day.ts @@ -10,6 +10,7 @@ const day: TranslationStrings = { 'day.climateHint': 'Historische Durchschnittswerte — echte Vorhersage verfügbar innerhalb von 16 Tagen vor diesem Datum.', 'day.noWeather': 'Keine Wetterdaten verfügbar. Füge einen Ort mit Koordinaten hinzu.', + 'day.weatherFor': 'Vorhersage für {name}', 'day.overview': 'Tagesübersicht', 'day.accommodation': 'Unterkunft', 'day.addAccommodation': 'Unterkunft hinzufügen', diff --git a/shared/src/i18n/en/day.ts b/shared/src/i18n/en/day.ts index 6290f503f..51eb03cb0 100644 --- a/shared/src/i18n/en/day.ts +++ b/shared/src/i18n/en/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Hourly Forecast', 'day.climateHint': 'Historical averages — real forecast available within 16 days of this date.', 'day.noWeather': 'No weather data available. Add a place with coordinates.', + 'day.weatherFor': 'Forecast for {name}', 'day.overview': 'Daily Overview', 'day.accommodation': 'Accommodation', 'day.addAccommodation': 'Add accommodation', diff --git a/shared/src/i18n/es/day.ts b/shared/src/i18n/es/day.ts index cd7a4d53f..06070a913 100644 --- a/shared/src/i18n/es/day.ts +++ b/shared/src/i18n/es/day.ts @@ -10,6 +10,7 @@ const day: TranslationStrings = { 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.', 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.', + 'day.weatherFor': 'Pronóstico para {name}', 'day.overview': 'Resumen diario', 'day.accommodation': 'Alojamiento', 'day.addAccommodation': 'Añadir alojamiento', diff --git a/shared/src/i18n/fr/day.ts b/shared/src/i18n/fr/day.ts index ebbe63376..bd9cc74fa 100644 --- a/shared/src/i18n/fr/day.ts +++ b/shared/src/i18n/fr/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Prévisions horaires', 'day.climateHint': 'Moyennes historiques — prévisions réelles disponibles dans les 16 jours précédant cette date.', 'day.noWeather': 'Aucune donnée météo disponible. Ajoutez un lieu avec des coordonnées.', + 'day.weatherFor': 'Prévisions pour {name}', 'day.overview': 'Aperçu du jour', 'day.accommodation': 'Hébergement', 'day.addAccommodation': 'Ajouter un hébergement', diff --git a/shared/src/i18n/gr/day.ts b/shared/src/i18n/gr/day.ts index 1b15be846..d335cbca3 100644 --- a/shared/src/i18n/gr/day.ts +++ b/shared/src/i18n/gr/day.ts @@ -10,6 +10,7 @@ const day: TranslationStrings = { 'day.climateHint': 'Ιστορικοί μέσοι όροι — η πραγματική πρόγνωση είναι διαθέσιμη εντός 16 ημερών από αυτή την ημερομηνία.', 'day.noWeather': 'Δεν υπάρχουν διαθέσιμα δεδομένα καιρού. Προσθέστε μια τοποθεσία με συντεταγμένες.', + 'day.weatherFor': 'Πρόγνωση για {name}', 'day.overview': 'Ημερήσια Επισκόπηση', 'day.accommodation': 'Διαμονή', 'day.addAccommodation': 'Προσθήκη διαμονής', diff --git a/shared/src/i18n/hu/day.ts b/shared/src/i18n/hu/day.ts index ef99b82ac..1ba90f673 100644 --- a/shared/src/i18n/hu/day.ts +++ b/shared/src/i18n/hu/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Óránkénti előrejelzés', 'day.climateHint': 'Történelmi átlagok — valós előrejelzés a dátum előtti 16 napon belül érhető el.', 'day.noWeather': 'Nem állnak rendelkezésre időjárási adatok. Adj hozzá egy helyet koordinátákkal.', + 'day.weatherFor': 'Előrejelzés: {name}', 'day.overview': 'Napi áttekintés', 'day.accommodation': 'Szállás', 'day.addAccommodation': 'Szállás hozzáadása', diff --git a/shared/src/i18n/id/day.ts b/shared/src/i18n/id/day.ts index e06f32c79..a640865f2 100644 --- a/shared/src/i18n/id/day.ts +++ b/shared/src/i18n/id/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Prakiraan Per Jam', 'day.climateHint': 'Rata-rata historis — prakiraan nyata tersedia dalam 16 hari dari tanggal ini.', 'day.noWeather': 'Data cuaca tidak tersedia. Tambahkan tempat dengan koordinat.', + 'day.weatherFor': 'Prakiraan untuk {name}', 'day.overview': 'Ikhtisar Harian', 'day.accommodation': 'Akomodasi', 'day.addAccommodation': 'Tambah akomodasi', diff --git a/shared/src/i18n/it/day.ts b/shared/src/i18n/it/day.ts index bc5e9fd07..7c68231f6 100644 --- a/shared/src/i18n/it/day.ts +++ b/shared/src/i18n/it/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Previsione oraria', 'day.climateHint': 'Medie storiche — previsioni reali disponibili entro 16 giorni da questa data.', 'day.noWeather': 'Nessun dato meteo disponibile. Aggiungi un luogo con coordinate.', + 'day.weatherFor': 'Previsioni per {name}', 'day.overview': 'Panoramica giornaliera', 'day.accommodation': 'Alloggio', 'day.addAccommodation': 'Aggiungi alloggio', diff --git a/shared/src/i18n/ja/day.ts b/shared/src/i18n/ja/day.ts index 865cf43ed..9fbb0b019 100644 --- a/shared/src/i18n/ja/day.ts +++ b/shared/src/i18n/ja/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': '時間別予報', 'day.climateHint': '過去の平均値 — 実際の予報はこの日付の16日前から表示されます。', 'day.noWeather': '天気データがありません。座標付きの場所を追加してください。', + 'day.weatherFor': '{name}の予報', 'day.overview': '1日の概要', 'day.accommodation': '宿泊先', 'day.addAccommodation': '宿泊先を追加', diff --git a/shared/src/i18n/ko/day.ts b/shared/src/i18n/ko/day.ts index 9817b6a73..628178e5a 100644 --- a/shared/src/i18n/ko/day.ts +++ b/shared/src/i18n/ko/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': '시간별 예보', 'day.climateHint': '역사적 평균값 — 이 날짜로부터 16일 이내의 실제 예보를 사용할 수 있습니다.', 'day.noWeather': '날씨 데이터가 없습니다. 좌표가 있는 장소를 추가하세요.', + 'day.weatherFor': '{name} 예보', 'day.overview': '일별 개요', 'day.accommodation': '숙박', 'day.addAccommodation': '숙박 추가', diff --git a/shared/src/i18n/nl/day.ts b/shared/src/i18n/nl/day.ts index e20f888eb..4f39b639e 100644 --- a/shared/src/i18n/nl/day.ts +++ b/shared/src/i18n/nl/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Uurlijkse voorspelling', 'day.climateHint': 'Historische gemiddelden — echte voorspelling beschikbaar binnen 16 dagen voor deze datum.', 'day.noWeather': 'Geen weergegevens beschikbaar. Voeg een plaats met coördinaten toe.', + 'day.weatherFor': 'Voorspelling voor {name}', 'day.overview': 'Dagoverzicht', 'day.accommodation': 'Accommodatie', 'day.addAccommodation': 'Accommodatie toevoegen', diff --git a/shared/src/i18n/pl/day.ts b/shared/src/i18n/pl/day.ts index ba395d6be..999100b22 100644 --- a/shared/src/i18n/pl/day.ts +++ b/shared/src/i18n/pl/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Prognoza godzinowa', 'day.climateHint': 'Historyczne średnie — rzeczywista prognoza dostępna na następne 16 dni od tej daty.', 'day.noWeather': 'Brak danych pogodowych. Dodaj miejsce ze współrzędnymi.', + 'day.weatherFor': 'Prognoza dla {name}', 'day.overview': 'Przegląd dnia', 'day.accommodation': 'Zakwaterowanie', 'day.addAccommodation': 'Dodaj zakwaterowanie', diff --git a/shared/src/i18n/ru/day.ts b/shared/src/i18n/ru/day.ts index c49fe0f50..21bee5147 100644 --- a/shared/src/i18n/ru/day.ts +++ b/shared/src/i18n/ru/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Почасовой прогноз', 'day.climateHint': 'Исторические средние — реальный прогноз доступен за 16 дней до этой даты.', 'day.noWeather': 'Данные о погоде недоступны. Добавьте место с координатами.', + 'day.weatherFor': 'Прогноз для {name}', 'day.overview': 'Обзор дня', 'day.accommodation': 'Жильё', 'day.addAccommodation': 'Добавить жильё', diff --git a/shared/src/i18n/sv/day.ts b/shared/src/i18n/sv/day.ts index 56d9b92a6..a12988992 100644 --- a/shared/src/i18n/sv/day.ts +++ b/shared/src/i18n/sv/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Prognos per timme', 'day.climateHint': 'Historiska medelvärden — prognos i realtid tillgänglig inom 16 dagar från detta datum.', 'day.noWeather': 'Det finns inga väderuppgifter tillgängliga. Lägg till en plats med koordinater.', + 'day.weatherFor': 'Prognos för {name}', 'day.overview': 'Daglig översikt', 'day.accommodation': 'Boende', 'day.addAccommodation': 'Lägg till boende', diff --git a/shared/src/i18n/tr/day.ts b/shared/src/i18n/tr/day.ts index e4e7b9f97..4c0a13bc2 100644 --- a/shared/src/i18n/tr/day.ts +++ b/shared/src/i18n/tr/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Saatlik Tahmin', 'day.climateHint': 'Tarihsel ortalamalar — gerçek tahmin bu tarihten 16 gün öncesine kadar kullanılabilir.', 'day.noWeather': 'Hava durumu verisi yok. Koordinatlı bir yer ekleyin.', + 'day.weatherFor': '{name} için tahmin', 'day.overview': 'Günlük Özet', 'day.accommodation': 'Konaklama', 'day.addAccommodation': 'Konaklama ekle', diff --git a/shared/src/i18n/uk/day.ts b/shared/src/i18n/uk/day.ts index 4e8bb956f..aa686a09e 100644 --- a/shared/src/i18n/uk/day.ts +++ b/shared/src/i18n/uk/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Погодний прогноз по годинах', 'day.climateHint': 'Історичні середні — реальний прогноз доступний за 16 днів до цієї дати.', 'day.noWeather': 'Дані про погоду недоступні. Додайте місце з координатами.', + 'day.weatherFor': 'Прогноз для {name}', 'day.overview': 'Огляд дня', 'day.accommodation': 'Проживання', 'day.addAccommodation': 'Додати проживання', diff --git a/shared/src/i18n/vi/day.ts b/shared/src/i18n/vi/day.ts index 7df367995..98c9b2905 100644 --- a/shared/src/i18n/vi/day.ts +++ b/shared/src/i18n/vi/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': 'Dự báo hàng giờ', 'day.climateHint': 'Trung bình lịch sử — dự báo thực tế có sẵn trong vòng 16 ngày kể từ ngày này.', 'day.noWeather': 'Không có dữ liệu thời tiết. Thêm địa điểm có tọa độ.', + 'day.weatherFor': 'Dự báo cho {name}', 'day.overview': 'Tổng quan hàng ngày', 'day.accommodation': 'Chỗ ở', 'day.addAccommodation': 'Thêm chỗ ở', diff --git a/shared/src/i18n/zh-TW/day.ts b/shared/src/i18n/zh-TW/day.ts index 59f241890..e3aef792a 100644 --- a/shared/src/i18n/zh-TW/day.ts +++ b/shared/src/i18n/zh-TW/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': '逐小時預報', 'day.climateHint': '歷史平均值——實際預報在該日期前 16 天內可用。', 'day.noWeather': '無天氣資料。請新增有座標的地點。', + 'day.weatherFor': '{name}的天氣預報', 'day.overview': '每日概覽', 'day.accommodation': '住宿', 'day.addAccommodation': '新增住宿', diff --git a/shared/src/i18n/zh/day.ts b/shared/src/i18n/zh/day.ts index dbd04fa4f..84de3aca6 100644 --- a/shared/src/i18n/zh/day.ts +++ b/shared/src/i18n/zh/day.ts @@ -9,6 +9,7 @@ const day: TranslationStrings = { 'day.hourlyForecast': '逐小时预报', 'day.climateHint': '历史平均值——实际预报在该日期前 16 天内可用。', 'day.noWeather': '无天气数据。请添加有坐标的地点。', + 'day.weatherFor': '{name}的天气预报', 'day.overview': '每日概览', 'day.accommodation': '住宿', 'day.addAccommodation': '添加住宿',