From e47c4b9d8115ab85945c86b0a0c6a3002fb289d0 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 12 Oct 2026 13:44:19 +0700 Subject: [PATCH] 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). --- AGENTS.md | 7 ++++ src/components/SpotMap.tsx | 6 ++-- src/components/WeatherLegend.tsx | 43 +++++++++++++++++++++++ src/i18n/legacy/weather.ts | 16 +++++++++ src/lib/weather.ts | 6 +++- src/pages/MapPage.tsx | 8 +++++ src/pages/SpotDetailPage.tsx | 60 ++++++++++++++++++++++++++++++-- 7 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 src/components/WeatherLegend.tsx 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 }) : ''} + +
+ {/* 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')}{' '} + + + + )} + {/* Loading Weather */} {weatherLoading && (