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:
parent
84c0ba7b3c
commit
e47c4b9d81
7 changed files with 139 additions and 7 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
<span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
|
||||
<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 class="text-right">
|
||||
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
|
||||
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
|
||||
</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>
|
||||
`;
|
||||
weatherSection.appendChild(currentWeather);
|
||||
|
|
|
|||
43
src/components/WeatherLegend.tsx
Normal file
43
src/components/WeatherLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<We
|
|||
const params = new URLSearchParams({
|
||||
latitude: lat.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',
|
||||
timezone: 'auto',
|
||||
forecast_days: '3',
|
||||
|
|
@ -216,6 +218,8 @@ export async function fetchWeatherForecast(lat: number, lng: number): Promise<We
|
|||
weatherCode: data.daily.weather_code[index],
|
||||
weatherIcon: weatherInfo.icon,
|
||||
precipitationProbability: data.daily.precipitation_probability_max[index] || 0,
|
||||
sunrise: data.daily.sunrise?.[index] ?? '',
|
||||
sunset: data.daily.sunset?.[index] ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|||
const SpotMap = lazy(() => 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 && (
|
||||
<div className="pointer-events-none absolute right-3 bottom-24 z-[1000]">
|
||||
<WeatherLegend />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapFooterStrip />
|
||||
|
||||
{/* Location permission help dialog */}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</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
|
||||
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)}
|
||||
</div>
|
||||
<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>
|
||||
) : (
|
||||
|
|
@ -1101,6 +1123,22 @@ export default function SpotDetailPage() {
|
|||
)}
|
||||
</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 */}
|
||||
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
|
|
@ -1138,6 +1176,22 @@ export default function SpotDetailPage() {
|
|||
</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 */}
|
||||
{weatherLoading && (
|
||||
<Card className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 border-2 border-blue-500/30">
|
||||
|
|
|
|||
Loading…
Reference in a new issue