feat(weather): legend, refresh/error states, sunrise/sunset, reason chips

- WeatherLegend overlay on the map (only while the weather layer is on), colours mirroring calculateSkateability
- Spot weather card: freshness line + manual refresh, and an error/retry state when Open-Meteo is unreachable
- Sunrise/sunset row (weather.ts now fetches Open-Meteo daily sunrise,sunset)
- Score reasons render as chips, not a bullet-joined run-on
- Map popup muted text gray-500 -> gray-300 for contrast
Units stay Celsius/km/h (deliberate).
This commit is contained in:
SkateSpots 2026-10-12 13:44:19 +07:00
parent 84c0ba7b3c
commit e47c4b9d81
7 changed files with 139 additions and 7 deletions

View file

@ -2405,6 +2405,13 @@ composed in `legacy/index.ts` and spread into `en`/`es` (same pattern as
- `weatherUi` — weather/AQI/skateability labels (60 keys; `weather.ts` gained - `weatherUi` — weather/AQI/skateability labels (60 keys; `weather.ts` gained
`*Key` fields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard, `*Key` fields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard,
the Sasquatch weather tool) the Sasquatch weather tool)
**Weather UI pass (2026-10-12):** added a map **legend**
(`components/WeatherLegend.tsx`; colours mirror `calculateSkateability`), a
**freshness + manual refresh** line and an **error/retry** state on the spot
weather card, **sunrise/sunset** (`weather.ts` fetches Open-Meteo daily
`sunrise,sunset`), and the score's reasons now render as **chips** instead of a
bullet-joined run-on. Popup `text-gray-500` → `gray-300` for contrast. Units stay
**Celsius / km/h** (deliberate — no unit toggle).
- `skaterLabelsUi` (21) — skater stance/style/level + sponsor tiers in `types.ts` - `skaterLabelsUi` (21) — skater stance/style/level + sponsor tiers in `types.ts`
- `notifyUi` (28) — OS notification titles/bodies (`OsNotificationBridge`) - `notifyUi` (28) — OS notification titles/bodies (`OsNotificationBridge`)
- `legacyMiscUi` (~160) — scattered component/page literals, image `alt`/`aria-label`, - `legacyMiscUi` (~160) — scattered component/page literals, image `alt`/`aria-label`,

View file

@ -563,7 +563,7 @@ function createPopupContent(
// written via textContent (never innerHTML) to neutralize any injected markup. // written via textContent (never innerHTML) to neutralize any injected markup.
if (spot.location) { if (spot.location) {
const location = document.createElement('p'); const location = document.createElement('p');
location.className = 'text-sm text-gray-500 mb-2 flex items-center gap-1'; location.className = 'text-sm text-gray-300 mb-2 flex items-center gap-1';
const pin = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const pin = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pin.setAttribute('class', 'h-3 w-3'); pin.setAttribute('class', 'h-3 w-3');
pin.setAttribute('viewBox', '0 0 24 24'); pin.setAttribute('viewBox', '0 0 24 24');
@ -598,14 +598,14 @@ function createPopupContent(
<span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span> <span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
<div> <div>
<div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div> <div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
<div class="text-xs text-gray-500">${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}</div> <div class="text-xs text-gray-300">${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}</div>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}"> <div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))} ${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
</div> </div>
<div class="text-xs text-gray-500">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div> <div class="text-xs text-gray-300">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div>
</div> </div>
`; `;
weatherSection.appendChild(currentWeather); weatherSection.appendChild(currentWeather);

View file

@ -0,0 +1,43 @@
import { useTranslation } from '@/contexts/language';
/**
* The skateability colour scale, mirroring `calculateSkateability` in lib/weather.
* Kept here (not derived at runtime) so the legend and the scorer can't drift
* silently — if the thresholds change there, change them here too.
*/
export const SKATEABILITY_LEVELS: { key: string; color: string }[] = [
{ key: 'weatherUi.skateability.perfect', color: '#00e400' },
{ key: 'weatherUi.skateability.good', color: '#92d050' },
{ key: 'weatherUi.skateability.okay', color: '#ffff00' },
{ key: 'weatherUi.skateability.poor', color: '#ff7e00' },
{ key: 'weatherUi.skateability.bad', color: '#ff0000' },
];
/**
* Small map overlay explaining the weather-layer marker colours. Rendered by
* MapPage only while the weather layer is on (the colours are meaningless
* otherwise). Non-interactive, so it never blocks map gestures.
*/
export function WeatherLegend({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div className={className}>
<div className="rounded-xl border border-white/10 bg-black/75 px-2.5 py-2 backdrop-blur">
<div className="mb-1 text-[10px] font-bold uppercase tracking-wide text-white/60">
{t('weatherUi.legendTitle')}
</div>
<div className="flex flex-col gap-1">
{SKATEABILITY_LEVELS.map((level) => (
<div key={level.key} className="flex items-center gap-1.5 text-[10px] text-white/85">
<span className="h-2.5 w-2.5 flex-shrink-0 rounded-full" style={{ backgroundColor: level.color }} />
{t(level.key)}
</div>
))}
</div>
<div className="mt-1 text-[9px] font-medium uppercase tracking-wide text-white/40">
{t('weatherUi.legendWeatherOnly')}
</div>
</div>
</div>
);
}

View file

@ -59,6 +59,14 @@ const weatherUi = {
bad: 'Bad', bad: 'Bad',
unrideable: 'Unrideable', unrideable: 'Unrideable',
}, },
updated: 'Updated',
refresh: 'Refresh',
unavailable: 'Weather unavailable.',
retry: 'Retry',
sunrise: 'Sunrise',
sunset: 'Sunset',
legendTitle: 'Weather score',
legendWeatherOnly: 'Weather only',
reason: { reason: {
currentlyRaining: 'Currently raining', currentlyRaining: 'Currently raining',
raining: 'Raining', raining: 'Raining',
@ -129,6 +137,14 @@ const weatherUi = {
bad: 'Malo', bad: 'Malo',
unrideable: 'No patinable', unrideable: 'No patinable',
}, },
updated: 'Actualizado',
refresh: 'Actualizar',
unavailable: 'Clima no disponible.',
retry: 'Reintentar',
sunrise: 'Amanecer',
sunset: 'Atardecer',
legendTitle: 'Puntuación del clima',
legendWeatherOnly: 'Solo clima',
reason: { reason: {
currentlyRaining: 'Está lloviendo ahora', currentlyRaining: 'Está lloviendo ahora',
raining: 'Lloviendo', raining: 'Lloviendo',

View file

@ -43,6 +43,8 @@ export interface WeatherForecast {
weatherCode: number; weatherCode: number;
weatherIcon: string; weatherIcon: string;
precipitationProbability: number; precipitationProbability: number;
sunrise: string;
sunset: string;
}[]; }[];
} }
@ -195,7 +197,7 @@ export async function fetchWeatherForecast(lat: number, lng: number): Promise<We
const params = new URLSearchParams({ const params = new URLSearchParams({
latitude: lat.toFixed(6), latitude: lat.toFixed(6),
longitude: lng.toFixed(6), longitude: lng.toFixed(6),
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max', daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset',
temperature_unit: 'celsius', temperature_unit: 'celsius',
timezone: 'auto', timezone: 'auto',
forecast_days: '3', forecast_days: '3',
@ -216,6 +218,8 @@ export async function fetchWeatherForecast(lat: number, lng: number): Promise<We
weatherCode: data.daily.weather_code[index], weatherCode: data.daily.weather_code[index],
weatherIcon: weatherInfo.icon, weatherIcon: weatherInfo.icon,
precipitationProbability: data.daily.precipitation_probability_max[index] || 0, precipitationProbability: data.daily.precipitation_probability_max[index] || 0,
sunrise: data.daily.sunrise?.[index] ?? '',
sunset: data.daily.sunset?.[index] ?? '',
}; };
}), }),
}; };

View file

@ -6,6 +6,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap }))); const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { MapFooterStrip } from '@/components/MapFooterStrip'; import { MapFooterStrip } from '@/components/MapFooterStrip';
import { WeatherLegend } from '@/components/WeatherLegend';
import { WeatherToggle } from '@/components/WeatherToggle'; import { WeatherToggle } from '@/components/WeatherToggle';
import { RouteDialog } from '@/components/RouteDialog'; import { RouteDialog } from '@/components/RouteDialog';
import { TrustFilter } from '@/components/TrustFilter'; import { TrustFilter } from '@/components/TrustFilter';
@ -1963,6 +1964,13 @@ export default function MapPage() {
{/* Slim, dismissible site-links strip — the desktop footer is hidden {/* Slim, dismissible site-links strip — the desktop footer is hidden
on this full-bleed route, so surface the links here instead. */} on this full-bleed route, so surface the links here instead. */}
{/* Weather score legend — only while the weather layer is on */}
{weatherEnabled && (
<div className="pointer-events-none absolute right-3 bottom-24 z-[1000]">
<WeatherLegend />
</div>
)}
<MapFooterStrip /> <MapFooterStrip />
{/* Location permission help dialog */} {/* Location permission help dialog */}

View file

@ -1,4 +1,4 @@
import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { dateLocaleCode, formatDistanceToNow } from '@/lib/dateFnsI18n';
import { labProfilePath } from '@/lib/labRoutes'; import { labProfilePath } from '@/lib/labRoutes';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
@ -57,6 +57,9 @@ import {
Wind, Wind,
Droplets, Droplets,
Sun, Sun,
Sunrise,
Moon,
RefreshCw,
ShoppingBag, ShoppingBag,
Plus, Plus,
Bookmark, Bookmark,
@ -243,7 +246,7 @@ export default function SpotDetailPage() {
}, [spot?.geohash]); }, [spot?.geohash]);
// Fetch weather data for this spot // Fetch weather data for this spot
const { data: weatherData, isLoading: weatherLoading } = useSpotWeather(spot?.geohash, true); const { data: weatherData, isLoading: weatherLoading, isFetching: weatherFetching, isError: weatherError, dataUpdatedAt: weatherUpdatedAt, refetch: refetchWeather } = useSpotWeather(spot?.geohash, true);
const handleShare = async () => { const handleShare = async () => {
if (navigator.share) { if (navigator.share) {
@ -1035,6 +1038,19 @@ export default function SpotDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{t('weatherUi.updated')} {weatherUpdatedAt ? formatDistanceToNow(weatherUpdatedAt, { addSuffix: true }) : ''}</span>
<button
type="button"
onClick={() => refetchWeather()}
disabled={weatherFetching}
className="inline-flex items-center gap-1 font-semibold transition-colors hover:text-white disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', weatherFetching && 'animate-spin')} />
{t('weatherUi.refresh')}
</button>
</div>
{/* Conditions score — WEATHER ONLY, and only once the place is {/* Conditions score — WEATHER ONLY, and only once the place is
confirmed (community or staff). On an unconfirmed pin a big confirmed (community or staff). On an unconfirmed pin a big
green "PERFECT 95/100" reads as endorsement of the *spot*, green "PERFECT 95/100" reads as endorsement of the *spot*,
@ -1056,7 +1072,13 @@ export default function SpotDetailPage() {
{t(weatherData.skateability.labelKey)} {t(weatherData.skateability.labelKey)}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground"> <div className="mt-2 text-xs text-muted-foreground">
{weatherData.skateability.reasonKeys.map((key) => t(key)).join(' • ')} <div className="flex flex-wrap gap-1.5">
{weatherData.skateability.reasonKeys.map((key) => (
<span key={key} className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] font-medium">
{t(key)}
</span>
))}
</div>
</div> </div>
</div> </div>
) : ( ) : (
@ -1101,6 +1123,22 @@ export default function SpotDetailPage() {
)} )}
</div> </div>
{/* Light: sunrise / sunset (skating is daylight-bound) */}
{weatherData.forecast?.daily?.[0]?.sunrise && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-3 border-t border-border text-sm">
<div className="flex items-center gap-2">
<Sunrise className="h-4 w-4 text-amber-400" />
<span className="text-xs text-muted-foreground">{t('weatherUi.sunrise')}</span>
<span className="font-medium">{new Date(weatherData.forecast.daily[0].sunrise).toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-indigo-300" />
<span className="text-xs text-muted-foreground">{t('weatherUi.sunset')}</span>
<span className="font-medium">{new Date(weatherData.forecast.daily[0].sunset).toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
)}
{/* 3-Day Forecast */} {/* 3-Day Forecast */}
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && ( {weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
<div className="pt-3 border-t border-border"> <div className="pt-3 border-t border-border">
@ -1138,6 +1176,22 @@ export default function SpotDetailPage() {
</Card> </Card>
)} )}
{/* Weather error (Open-Meteo unreachable) */}
{weatherError && !weatherData && (
<Card className="border-2 border-border">
<CardContent className="py-4 text-center text-sm text-muted-foreground">
{t('weatherUi.unavailable')}{' '}
<button
type="button"
onClick={() => refetchWeather()}
className="font-semibold text-white underline"
>
{t('weatherUi.retry')}
</button>
</CardContent>
</Card>
)}
{/* Loading Weather */} {/* Loading Weather */}
{weatherLoading && ( {weatherLoading && (
<Card className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 border-2 border-blue-500/30"> <Card className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 border-2 border-blue-500/30">