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
@@ -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>
)}