diff --git a/AGENTS.md b/AGENTS.md
index 964c192..534cb35 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
`*Key` fields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard,
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`
- `notifyUi` (28) — OS notification titles/bodies (`OsNotificationBridge`)
- `legacyMiscUi` (~160) — scattered component/page literals, image `alt`/`aria-label`,
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx
index e2a3b5d..5bd1ce1 100644
--- a/src/components/SpotMap.tsx
+++ b/src/components/SpotMap.tsx
@@ -563,7 +563,7 @@ function createPopupContent(
// written via textContent (never innerHTML) to neutralize any injected markup.
if (spot.location) {
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');
pin.setAttribute('class', 'h-3 w-3');
pin.setAttribute('viewBox', '0 0 24 24');
@@ -598,14 +598,14 @@ function createPopupContent(
${escapeHtml(weatherData.weather.weatherIcon)}
${escapeHtml(weatherData.weather.temperature)}°C
-
${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}
+
${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
-
${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}
+
${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}
`;
weatherSection.appendChild(currentWeather);
diff --git a/src/components/WeatherLegend.tsx b/src/components/WeatherLegend.tsx
new file mode 100644
index 0000000..dc9b6a8
--- /dev/null
+++ b/src/components/WeatherLegend.tsx
@@ -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 (
+
+
+
+ {t('weatherUi.legendTitle')}
+
+
+ {SKATEABILITY_LEVELS.map((level) => (
+
+
+ {t(level.key)}
+
+ ))}
+
+
+ {t('weatherUi.legendWeatherOnly')}
+
+
+
+ );
+}
diff --git a/src/i18n/legacy/weather.ts b/src/i18n/legacy/weather.ts
index 72e9d4b..5731613 100644
--- a/src/i18n/legacy/weather.ts
+++ b/src/i18n/legacy/weather.ts
@@ -59,6 +59,14 @@ const weatherUi = {
bad: 'Bad',
unrideable: 'Unrideable',
},
+ updated: 'Updated',
+ refresh: 'Refresh',
+ unavailable: 'Weather unavailable.',
+ retry: 'Retry',
+ sunrise: 'Sunrise',
+ sunset: 'Sunset',
+ legendTitle: 'Weather score',
+ legendWeatherOnly: 'Weather only',
reason: {
currentlyRaining: 'Currently raining',
raining: 'Raining',
@@ -129,6 +137,14 @@ const weatherUi = {
bad: 'Malo',
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: {
currentlyRaining: 'Está lloviendo ahora',
raining: 'Lloviendo',
diff --git a/src/lib/weather.ts b/src/lib/weather.ts
index 21b694b..f164e22 100644
--- a/src/lib/weather.ts
+++ b/src/lib/weather.ts
@@ -43,6 +43,8 @@ export interface WeatherForecast {
weatherCode: number;
weatherIcon: string;
precipitationProbability: number;
+ sunrise: string;
+ sunset: string;
}[];
}
@@ -195,7 +197,7 @@ export async function fetchWeatherForecast(lat: number, lng: number): Promise import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { MapFooterStrip } from '@/components/MapFooterStrip';
+import { WeatherLegend } from '@/components/WeatherLegend';
import { WeatherToggle } from '@/components/WeatherToggle';
import { RouteDialog } from '@/components/RouteDialog';
import { TrustFilter } from '@/components/TrustFilter';
@@ -1963,6 +1964,13 @@ export default function MapPage() {
{/* Slim, dismissible site-links strip — the desktop footer is hidden
on this full-bleed route, so surface the links here instead. */}
+ {/* Weather score legend — only while the weather layer is on */}
+ {weatherEnabled && (
+
+
+
+ )}
+
{/* Location permission help dialog */}
diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx
index c425314..7ecc879 100644
--- a/src/pages/SpotDetailPage.tsx
+++ b/src/pages/SpotDetailPage.tsx
@@ -1,4 +1,4 @@
-import { dateLocaleCode } from '@/lib/dateFnsI18n';
+import { dateLocaleCode, formatDistanceToNow } from '@/lib/dateFnsI18n';
import { labProfilePath } from '@/lib/labRoutes';
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
@@ -57,6 +57,9 @@ import {
Wind,
Droplets,
Sun,
+ Sunrise,
+ Moon,
+ RefreshCw,
ShoppingBag,
Plus,
Bookmark,
@@ -243,7 +246,7 @@ export default function SpotDetailPage() {
}, [spot?.geohash]);
// 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 () => {
if (navigator.share) {
@@ -1035,6 +1038,19 @@ export default function SpotDetailPage() {
+
+ {t('weatherUi.updated')} {weatherUpdatedAt ? formatDistanceToNow(weatherUpdatedAt, { addSuffix: true }) : ''}
+ refetchWeather()}
+ disabled={weatherFetching}
+ className="inline-flex items-center gap-1 font-semibold transition-colors hover:text-white disabled:opacity-50"
+ >
+
+ {t('weatherUi.refresh')}
+
+
+
{/* Conditions score — WEATHER ONLY, and only once the place is
confirmed (community or staff). On an unconfirmed pin a big
green "PERFECT 95/100" reads as endorsement of the *spot*,
@@ -1056,7 +1072,13 @@ export default function SpotDetailPage() {
{t(weatherData.skateability.labelKey)}
- {weatherData.skateability.reasonKeys.map((key) => t(key)).join(' • ')}
+
+ {weatherData.skateability.reasonKeys.map((key) => (
+
+ {t(key)}
+
+ ))}
+
) : (
@@ -1101,6 +1123,22 @@ export default function SpotDetailPage() {
)}
+ {/* Light: sunrise / sunset (skating is daylight-bound) */}
+ {weatherData.forecast?.daily?.[0]?.sunrise && (
+
+
+
+ {t('weatherUi.sunrise')}
+ {new Date(weatherData.forecast.daily[0].sunrise).toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ {t('weatherUi.sunset')}
+ {new Date(weatherData.forecast.daily[0].sunset).toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' })}
+
+
+ )}
+
{/* 3-Day Forecast */}
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
@@ -1138,6 +1176,22 @@ export default function SpotDetailPage() {
)}
+ {/* Weather error (Open-Meteo unreachable) */}
+ {weatherError && !weatherData && (
+
+
+ {t('weatherUi.unavailable')}{' '}
+ refetchWeather()}
+ className="font-semibold text-white underline"
+ >
+ {t('weatherUi.retry')}
+
+
+
+ )}
+
{/* Loading Weather */}
{weatherLoading && (