mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': 'التوقعات بالساعة',
|
||||
'day.climateHint': 'متوسطات تاريخية — التوقعات الفعلية متاحة خلال 16 يومًا من هذا التاريخ.',
|
||||
'day.noWeather': 'لا تتوفر بيانات طقس. أضف مكانًا بإحداثيات.',
|
||||
'day.weatherFor': 'التوقعات لـ {name}',
|
||||
'day.overview': 'ملخص اليوم',
|
||||
'day.accommodation': 'الإقامة',
|
||||
'day.addAccommodation': 'إضافة إقامة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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í',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,6 +10,7 @@ const day: TranslationStrings = {
|
||||
'day.climateHint':
|
||||
'Ιστορικοί μέσοι όροι — η πραγματική πρόγνωση είναι διαθέσιμη εντός 16 ημερών από αυτή την ημερομηνία.',
|
||||
'day.noWeather': 'Δεν υπάρχουν διαθέσιμα δεδομένα καιρού. Προσθέστε μια τοποθεσία με συντεταγμένες.',
|
||||
'day.weatherFor': 'Πρόγνωση για {name}',
|
||||
'day.overview': 'Ημερήσια Επισκόπηση',
|
||||
'day.accommodation': 'Διαμονή',
|
||||
'day.addAccommodation': 'Προσθήκη διαμονής',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': '時間別予報',
|
||||
'day.climateHint': '過去の平均値 — 実際の予報はこの日付の16日前から表示されます。',
|
||||
'day.noWeather': '天気データがありません。座標付きの場所を追加してください。',
|
||||
'day.weatherFor': '{name}の予報',
|
||||
'day.overview': '1日の概要',
|
||||
'day.accommodation': '宿泊先',
|
||||
'day.addAccommodation': '宿泊先を追加',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': '시간별 예보',
|
||||
'day.climateHint': '역사적 평균값 — 이 날짜로부터 16일 이내의 실제 예보를 사용할 수 있습니다.',
|
||||
'day.noWeather': '날씨 데이터가 없습니다. 좌표가 있는 장소를 추가하세요.',
|
||||
'day.weatherFor': '{name} 예보',
|
||||
'day.overview': '일별 개요',
|
||||
'day.accommodation': '숙박',
|
||||
'day.addAccommodation': '숙박 추가',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': 'Почасовой прогноз',
|
||||
'day.climateHint': 'Исторические средние — реальный прогноз доступен за 16 дней до этой даты.',
|
||||
'day.noWeather': 'Данные о погоде недоступны. Добавьте место с координатами.',
|
||||
'day.weatherFor': 'Прогноз для {name}',
|
||||
'day.overview': 'Обзор дня',
|
||||
'day.accommodation': 'Жильё',
|
||||
'day.addAccommodation': 'Добавить жильё',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': 'Погодний прогноз по годинах',
|
||||
'day.climateHint': 'Історичні середні — реальний прогноз доступний за 16 днів до цієї дати.',
|
||||
'day.noWeather': 'Дані про погоду недоступні. Додайте місце з координатами.',
|
||||
'day.weatherFor': 'Прогноз для {name}',
|
||||
'day.overview': 'Огляд дня',
|
||||
'day.accommodation': 'Проживання',
|
||||
'day.addAccommodation': 'Додати проживання',
|
||||
|
||||
@@ -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ỗ ở',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': '逐小時預報',
|
||||
'day.climateHint': '歷史平均值——實際預報在該日期前 16 天內可用。',
|
||||
'day.noWeather': '無天氣資料。請新增有座標的地點。',
|
||||
'day.weatherFor': '{name}的天氣預報',
|
||||
'day.overview': '每日概覽',
|
||||
'day.accommodation': '住宿',
|
||||
'day.addAccommodation': '新增住宿',
|
||||
|
||||
@@ -9,6 +9,7 @@ const day: TranslationStrings = {
|
||||
'day.hourlyForecast': '逐小时预报',
|
||||
'day.climateHint': '历史平均值——实际预报在该日期前 16 天内可用。',
|
||||
'day.noWeather': '无天气数据。请添加有坐标的地点。',
|
||||
'day.weatherFor': '{name}的天气预报',
|
||||
'day.overview': '每日概览',
|
||||
'day.accommodation': '住宿',
|
||||
'day.addAccommodation': '添加住宿',
|
||||
|
||||
Reference in New Issue
Block a user