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:
parent
e47c4b9d81
commit
54882285b6
8 changed files with 34 additions and 18 deletions
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
15
src/lib/skateabilityLevels.ts
Normal file
15
src/lib/skateabilityLevels.ts
Normal 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' },
|
||||||
|
];
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue