fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167)

This commit is contained in:
Maurice
2026-09-01 15:48:37 +02:00
committed by Maurice
parent c21e86e3bd
commit 03bd5c77af
41 changed files with 568 additions and 105 deletions
+2 -1
View File
@@ -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<WeatherResult> => 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<WeatherResult> => 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<WeatherResult> => 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<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -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
</div>
) : weather ? (
<div>
{/* Which place the forecast is for — on a roadtrip "the day's weather"
is ambiguous without it (#2167). */}
{weatherPlaceName && (
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{t('day.weatherFor', { name: weatherPlaceName })}
</div>
)}
{/* Summary row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -87,7 +87,9 @@ vi.mock('../../hooks/useDayNotes', () => ({
}))
vi.mock('../Weather/WeatherWidget', () => ({
default: () => <span data-testid="weather-widget" />,
default: (props: { locationName?: string | null }) => (
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
),
}))
// 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(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [located] })} />)
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(<DayPlanSidebar {...makeDefaultProps({ days: [day, day2], accommodations: accommodations as any })} />)
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(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
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', () => {
@@ -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 (
<div style={{
// With weather the badge is a tall stack and has to start at
@@ -1700,7 +1699,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<>
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
<div style={{ padding: '3px 0 4px' }}>
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
<WeatherWidget lat={wLat ?? null} lng={wLng ?? null} date={day.date} stacked locationName={weatherName} />
</div>
</>
)}
@@ -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(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
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(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
// 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(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
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(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" locationName="Rome" />)
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(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
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()
})
})
+64 -38
View File
@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
<div title={title} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
<WeatherIcon main={weather.main} size={13} />
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
</div>
@@ -124,7 +150,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
if (compact) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
<span title={title} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
<WeatherIcon main={weather.main} size={12} />
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
</span>
@@ -132,7 +158,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
<div title={title} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
<WeatherIcon main={weather.main} size={15} />
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
{weather.description && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
@@ -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') {
@@ -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 }: {
<button
type="button"
onClick={onOpenDay}
aria-label={openLabel}
aria-label={weatherLabel || openLabel}
title={tl.weatherPlaceName || undefined}
className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap px-1.5 py-1 text-[0.71875rem] font-semibold"
>
<WeatherIcon size={13} strokeWidth={2} />
@@ -99,25 +99,25 @@ export function useMPlanTimeline(planner: TripPlanner) {
const upNext = useMemo(() => findUpNext(day, dayAssignments, now), [day, dayAssignments, now])
// ── Weather chip — anchored to the day's first located stop, else its hotel ──
const weatherCoords = useMemo<{ lat: number; lng: number } | null>(() => {
const weatherAnchor = useMemo<{ lat: number; lng: number; name: string | null } | null>(() => {
const located = dayAssignments.find(a => a.place?.lat != null && a.place?.lng != null)
if (located) return { lat: located.place!.lat!, lng: located.place!.lng! }
if (located) return { lat: located.place!.lat!, lng: located.place!.lng!, name: located.place!.name ?? null }
const hotel = day ? getDayBookendHotels(day, days, tripAccommodations).morning : undefined
if (hotel && hotel.place_lat != null && hotel.place_lng != null) {
return { lat: hotel.place_lat, lng: hotel.place_lng }
return { lat: hotel.place_lat, lng: hotel.place_lng, name: hotel.place_name ?? null }
}
return null
}, [day, dayAssignments, days, tripAccommodations])
const [weather, setWeather] = useState<WeatherResult | null>(null)
useEffect(() => {
if (!day?.date || !weatherCoords) { setWeather(null); return }
if (!day?.date || !weatherAnchor) { setWeather(null); return }
let cancelled = false
weatherApi.get(weatherCoords.lat, weatherCoords.lng, day.date.slice(0, 10))
weatherApi.get(weatherAnchor.lat, weatherAnchor.lng, day.date.slice(0, 10), language)
.then(data => { if (!cancelled) setWeather(data.error ? null : data) })
.catch(() => { if (!cancelled) setWeather(null) })
return () => { cancelled = true }
}, [day?.date, weatherCoords])
}, [day?.date, weatherAnchor, language])
// ── Expanded auto-transit rows ──
const [openTransitKeys, setOpenTransitKeys] = useState<Set<string>>(new Set())
@@ -378,6 +378,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
return {
day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext,
weatherPlaceName: weatherAnchor?.name ?? null,
language, timeFormat: settings.time_format,
openTransitKeys, toggleTransit,
moveRow, removeAssignment, editAssignment, editTransport, openTransitJourney,
@@ -68,11 +68,17 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
return [...(planner.assignments[String(day.id)] || [])].sort((a, b) => a.order_index - b.order_index)
}, [day, planner.assignments])
// Weather anchor: the first assigned place with coordinates, else any trip place.
const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place
|| planner.places.find(p => p.lat && p.lng)
const lat = geoPlace?.lat ?? null
const lng = geoPlace?.lng ?? null
// Weather anchor, day-local only (#2167): the day's first located stop, else the
// hotel you wake up in (unconditional bookend lookup, mirroring useMPlanTimeline).
// Never a place from another day — on a roadtrip that silently showed another
// city's weather with nothing naming the place.
const locatedPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place
const weatherHotel = day && !locatedPlace
? getDayBookendHotels(day, planner.days, planner.tripAccommodations).morning
: undefined
const lat = locatedPlace?.lat ?? weatherHotel?.place_lat ?? null
const lng = locatedPlace?.lng ?? weatherHotel?.place_lng ?? null
const weatherPlaceName = locatedPlace?.name ?? weatherHotel?.place_name ?? null
const [weather, setWeather] = useState<WeatherResult | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
@@ -304,6 +310,13 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
<span className="capitalize">{weather.description}</span>
</span>
</div>
{/* Which place the forecast is for — on a roadtrip "the day's
weather" is ambiguous without it (#2167). */}
{weatherPlaceName && (
<div className="mt-[6px] font-geist text-[0.625rem] text-m-faint">
{t('day.weatherFor', { name: weatherPlaceName })}
</div>
)}
{weather.type === 'climate' && (
<div className="mt-[6px] font-geist text-[0.625rem] italic text-m-faint">{t('day.climateHint')}</div>
)}
+12 -3
View File
@@ -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 (
<DayDetailPanel
day={currentDay}
@@ -601,8 +609,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId}
assignments={assignments}
reservations={reservations}
lat={geoPlace?.lat}
lng={geoPlace?.lng}
lat={weatherLat}
lng={weatherLng}
weatherPlaceName={weatherPlaceName}
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
onAccommodationChange={loadAccommodations}
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
+13 -3
View File
@@ -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 () => {
+2 -2
View File
@@ -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()
}
@@ -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 () => {
@@ -95,6 +95,7 @@ function buildTl(over: Record<string, unknown> = {}): 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 }],
@@ -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 () => {
+84 -14
View File
@@ -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<WeatherResult | null> {
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<WeatherResult> {
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<WeatherResult> {
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<WeatherResult> {
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<WeatherResult> {
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 ────────────────────────────────────────────────────────────
@@ -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)', () => {
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': 'التوقعات بالساعة',
'day.climateHint': 'متوسطات تاريخية — التوقعات الفعلية متاحة خلال 16 يومًا من هذا التاريخ.',
'day.noWeather': 'لا تتوفر بيانات طقس. أضف مكانًا بإحداثيات.',
'day.weatherFor': 'التوقعات لـ {name}',
'day.overview': 'ملخص اليوم',
'day.accommodation': 'الإقامة',
'day.addAccommodation': 'إضافة إقامة',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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í',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -10,6 +10,7 @@ const day: TranslationStrings = {
'day.climateHint':
'Ιστορικοί μέσοι όροι — η πραγματική πρόγνωση είναι διαθέσιμη εντός 16 ημερών από αυτή την ημερομηνία.',
'day.noWeather': 'Δεν υπάρχουν διαθέσιμα δεδομένα καιρού. Προσθέστε μια τοποθεσία με συντεταγμένες.',
'day.weatherFor': 'Πρόγνωση για {name}',
'day.overview': 'Ημερήσια Επισκόπηση',
'day.accommodation': 'Διαμονή',
'day.addAccommodation': 'Προσθήκη διαμονής',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': '時間別予報',
'day.climateHint': '過去の平均値 — 実際の予報はこの日付の16日前から表示されます。',
'day.noWeather': '天気データがありません。座標付きの場所を追加してください。',
'day.weatherFor': '{name}の予報',
'day.overview': '1日の概要',
'day.accommodation': '宿泊先',
'day.addAccommodation': '宿泊先を追加',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': '시간별 예보',
'day.climateHint': '역사적 평균값 — 이 날짜로부터 16일 이내의 실제 예보를 사용할 수 있습니다.',
'day.noWeather': '날씨 데이터가 없습니다. 좌표가 있는 장소를 추가하세요.',
'day.weatherFor': '{name} 예보',
'day.overview': '일별 개요',
'day.accommodation': '숙박',
'day.addAccommodation': '숙박 추가',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': 'Почасовой прогноз',
'day.climateHint': 'Исторические средние — реальный прогноз доступен за 16 дней до этой даты.',
'day.noWeather': 'Данные о погоде недоступны. Добавьте место с координатами.',
'day.weatherFor': 'Прогноз для {name}',
'day.overview': 'Обзор дня',
'day.accommodation': 'Жильё',
'day.addAccommodation': 'Добавить жильё',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': 'Погодний прогноз по годинах',
'day.climateHint': 'Історичні середні — реальний прогноз доступний за 16 днів до цієї дати.',
'day.noWeather': 'Дані про погоду недоступні. Додайте місце з координатами.',
'day.weatherFor': 'Прогноз для {name}',
'day.overview': 'Огляд дня',
'day.accommodation': 'Проживання',
'day.addAccommodation': 'Додати проживання',
+1
View File
@@ -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ỗ ở',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': '逐小時預報',
'day.climateHint': '歷史平均值——實際預報在該日期前 16 天內可用。',
'day.noWeather': '無天氣資料。請新增有座標的地點。',
'day.weatherFor': '{name}的天氣預報',
'day.overview': '每日概覽',
'day.accommodation': '住宿',
'day.addAccommodation': '新增住宿',
+1
View File
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
'day.hourlyForecast': '逐小时预报',
'day.climateHint': '历史平均值——实际预报在该日期前 16 天内可用。',
'day.noWeather': '无天气数据。请添加有坐标的地点。',
'day.weatherFor': '{name}的天气预报',
'day.overview': '每日概览',
'day.accommodation': '住宿',
'day.addAccommodation': '添加住宿',