feat(weather): 6-day forecast

FORECAST_DAYS=6 in lib/weather.ts is the single source for the fetch length and the UI labels (spot card + map popup). New count-interpolated labels spotUi.forecastDays / mapUi.forecastDays (en/es; other locales fall back to English). Legend colours moved to lib/skateabilityLevels.ts to keep the react-refresh lint at zero.
This commit is contained in:
SkateSpots 2026-10-12 13:51:29 +07:00
parent e47c4b9d81
commit 54882285b6
8 changed files with 34 additions and 18 deletions

View file

@ -2411,7 +2411,11 @@ composed in `legacy/index.ts` and spread into `en`/`es` (same pattern as
weather card, **sunrise/sunset** (`weather.ts` fetches Open-Meteo daily weather card, **sunrise/sunset** (`weather.ts` fetches Open-Meteo daily
`sunrise,sunset`), and the score's reasons now render as **chips** instead of a `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 bullet-joined run-on. Popup `text-gray-500` → `gray-300` for contrast. Units stay
**Celsius / km/h** (deliberate — no unit toggle). **Celsius / km/h** (deliberate — no unit toggle). The forecast is now **6 days**
(`FORECAST_DAYS` in `lib/weather.ts` is the single source for the fetch and the
labels; the legend colours live in `lib/skateabilityLevels.ts`). Labels use
`spotUi.forecastDays` / `mapUi.forecastDays` with a `{{count}}` param (the other
locales fall back to English until translated).
- `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

@ -12,6 +12,7 @@ import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces'; import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { createCachedTileLayer } from '@/lib/cachedTileLayer'; import { createCachedTileLayer } from '@/lib/cachedTileLayer';
import { FORECAST_DAYS } from '@/lib/weather';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
/** Translation function shape, so module-level popup builders can receive `t`. */ /** Translation function shape, so module-level popup builders can receive `t`. */
@ -627,7 +628,7 @@ function createPopupContent(
const forecastTitle = document.createElement('div'); const forecastTitle = document.createElement('div');
forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700'; forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700';
forecastTitle.textContent = t('mapUi.threeDayForecast'); forecastTitle.textContent = t('mapUi.forecastDays', { count: FORECAST_DAYS });
forecastDiv.appendChild(forecastTitle); forecastDiv.appendChild(forecastTitle);
const forecastGrid = document.createElement('div'); const forecastGrid = document.createElement('div');

View file

@ -1,17 +1,5 @@
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { SKATEABILITY_LEVELS } from '@/lib/skateabilityLevels';
/**
* 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 * Small map overlay explaining the weather-layer marker colours. Rendered by

View file

@ -934,6 +934,7 @@ mapUi: {
humidity: 'Humidity', humidity: 'Humidity',
airQuality: 'Air quality', airQuality: 'Air quality',
threeDayForecast: '3-Day Forecast', threeDayForecast: '3-Day Forecast',
forecastDays: '{{count}}-Day Forecast',
gearForSale: 'Gear for sale ({{count}})', gearForSale: 'Gear for sale ({{count}})',
viewItems: 'View Items →', viewItems: 'View Items →',
viewAllItems: 'View All {{count}} Items →', viewAllItems: 'View All {{count}} Items →',
@ -1366,6 +1367,7 @@ spotUi: {
tagsSuggestedDesc: 'Your tag suggestions have been submitted.', tagsSuggestedDesc: 'Your tag suggestions have been submitted.',
takePhoto: 'Take Photo', takePhoto: 'Take Photo',
threeDayForecast: '3-Day Forecast', threeDayForecast: '3-Day Forecast',
forecastDays: '{{count}}-Day Forecast',
tipTheMapper: 'Tip the Mapper', tipTheMapper: 'Tip the Mapper',
tipTheMapperDesc: 'Found this spot useful? Send a zap to thank the mapper!', tipTheMapperDesc: 'Found this spot useful? Send a zap to thank the mapper!',
titleRequired: 'Title required', titleRequired: 'Title required',

View file

@ -934,6 +934,7 @@ mapUi: {
humidity: 'Humedad', humidity: 'Humedad',
airQuality: 'Calidad del aire', airQuality: 'Calidad del aire',
threeDayForecast: 'Previsión a 3 días', threeDayForecast: 'Previsión a 3 días',
forecastDays: 'Pronóstico de {{count}} días',
gearForSale: 'Equipo en venta ({{count}})', gearForSale: 'Equipo en venta ({{count}})',
viewItems: 'Ver artículos →', viewItems: 'Ver artículos →',
viewAllItems: 'Ver los {{count}} artículos →', viewAllItems: 'Ver los {{count}} artículos →',
@ -1366,6 +1367,7 @@ spotUi: {
tagsSuggestedDesc: 'Tus sugerencias de etiquetas se han enviado.', tagsSuggestedDesc: 'Tus sugerencias de etiquetas se han enviado.',
takePhoto: 'Hacer foto', takePhoto: 'Hacer foto',
threeDayForecast: 'Previsión a 3 días', threeDayForecast: 'Previsión a 3 días',
forecastDays: 'Pronóstico de {{count}} días',
tipTheMapper: 'Invita al autor', tipTheMapper: 'Invita al autor',
tipTheMapperDesc: '¿Te ha servido este spot? ¡Envía un zap para agradecer al autor!', tipTheMapperDesc: '¿Te ha servido este spot? ¡Envía un zap para agradecer al autor!',
titleRequired: 'Título obligatorio', titleRequired: 'Título obligatorio',

View file

@ -0,0 +1,15 @@
/**
* The skateability colour scale, shown in the map's weather legend.
*
* Kept as a plain module (not exported from a component file — that trips the
* `react-refresh` lint rule) and mirrors the colours emitted by
* `calculateSkateability` in `lib/weather.ts`. If a threshold there changes,
* change it 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' },
];

View file

@ -190,8 +190,11 @@ export async function fetchAirQuality(lat: number, lng: number): Promise<AirQual
}; };
} }
/** Forecast length requested and displayed (Open-Meteo supports up to 16 days). */
export const FORECAST_DAYS = 6;
/** /**
* Fetch 3-day weather forecast for a location * Fetch a multi-day weather forecast for a location
*/ */
export async function fetchWeatherForecast(lat: number, lng: number): Promise<WeatherForecast> { export async function fetchWeatherForecast(lat: number, lng: number): Promise<WeatherForecast> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -200,7 +203,7 @@ export async function fetchWeatherForecast(lat: number, lng: number): Promise<We
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset', 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: String(FORECAST_DAYS),
}); });
const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);

View file

@ -1,4 +1,5 @@
import { dateLocaleCode, formatDistanceToNow } from '@/lib/dateFnsI18n'; import { dateLocaleCode, formatDistanceToNow } from '@/lib/dateFnsI18n';
import { FORECAST_DAYS } from '@/lib/weather';
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';
@ -1142,7 +1143,7 @@ export default function SpotDetailPage() {
{/* 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">
<div className="text-sm font-semibold mb-3">{t('spotUi.threeDayForecast')}</div> <div className="text-sm font-semibold mb-3">{t('spotUi.forecastDays', { count: FORECAST_DAYS })}</div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{weatherData.forecast.daily.map((day, idx) => { {weatherData.forecast.daily.map((day, idx) => {
const date = new Date(day.date); const date = new Date(day.date);