This commit is contained in:
Vincent van der Wal
2026-08-01 15:07:28 +02:00
parent 9806396476
commit 774afa6c65
45 changed files with 1515 additions and 239 deletions
+4
View File
@@ -26,3 +26,7 @@ AGENTS.md
# Local agent/editor config # Local agent/editor config
.claude/ .claude/
# paraglide compiles the message runtime into the source tree
/src/lib/paraglide
/.inlang
+4
View File
@@ -25,6 +25,10 @@ export default defineConfig(
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off', 'no-undef': 'off',
// Internal links go through `href()` / `gotoLocalized()` from $lib/i18n,
// which call resolve() and then add the locale prefix. The rule only
// recognises a literal resolve() call, so it cannot see through them.
'svelte/no-navigation-without-resolve': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Tage-Vorhersage",
"nav_compare": "Modellvergleich",
"nav_14day": "14-Tage-Vorhersage",
"nav_seasonal": "Saisonal",
"nav_historical": "Rückblick",
"nav_maps": "Karten",
"nav_collapse": "Einklappen",
"nav_expand_sidebar": "Seitenleiste ausklappen",
"nav_collapse_sidebar": "Seitenleiste einklappen",
"nav_home": "Drizzli Startseite",
"nav_toggle_menu": "Menü umschalten",
"search_placeholder": "Ort suchen …",
"settings_title": "Einstellungen",
"units_title": "Einheiten",
"units_aria": "Maßeinheiten wählen",
"unit_temperature": "Temperatur",
"unit_wind_speed": "Windgeschwindigkeit",
"unit_precipitation": "Niederschlag",
"theme_label": "Design",
"theme_system": "System",
"theme_light": "Hell",
"theme_dark": "Dunkel",
"theme_follow_system": "Design: System folgen",
"theme_light_title": "Design: hell",
"theme_dark_title": "Design: dunkel",
"language_label": "Sprache",
"supporter_active": "Unterstützer-Extras aktiv",
"supporter_support": "Drizz.li unterstützen",
"supporter_manage_key": "Zugangsschlüssel verwalten",
"supporter_unlock": "Unterstützer-Extras freischalten",
"supporter_badge": "Unterstützer",
"strip_past_label": "zurück",
"strip_past_aria": "Die letzten 3 Tage laden",
"strip_days_label": "Tage",
"strip_extend_aria": "Die vollen 15 Tage anzeigen",
"strip_history_label": "Archiv",
"strip_history_aria": "Wetterarchiv öffnen",
"strip_history_title": "Rückblick: jedes Datum zurück bis 1940",
"strip_seasonal_label": "saisonal",
"strip_seasonal_aria": "Saisonale Aussichten öffnen",
"strip_seasonal_title": "Saisonale Aussichten: Monatstrends für die kommenden Monate",
"day_today": "Heute",
"day_tomorrow": "Morgen",
"day_yesterday": "Gestern",
"page_week_subtitle": "7-Tage-Vorhersage",
"page_compare_subtitle": "Modellvergleich",
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
"page_seasonal_subtitle": "Saisonale Aussichten",
"page_historical_subtitle": "Wetterrückblick",
"hourly_heading": "stündlich",
"hourly_variables": "Variablen",
"meteograms_heading": "Meteogramme",
"meteograms_customize": "Anpassen",
"meteograms_zoom_hint": "Ziehen oder",
"meteograms_zoom_hint_end": "+ Scrollen zum Zoomen",
"range_today": "Heute",
"range_selected_day": "Gewählter Tag",
"range_3_days": "3 Tage",
"range_5_days": "5 Tage",
"range_all": "Alles",
"range_group_aria": "Zeitraum des Diagramms",
"reset_zoom": "Zoom zurücksetzen",
"default_range_title": "Standard-Zeitraum",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 Tage auf dem Handy, alles auf größeren Bildschirmen",
"summary_heading": "in Worten",
"summary_no_data": "Für diesen Tag liegen keine Stundenwerte vor.",
"label_sunrise": "Sonnenaufgang",
"label_sunset": "Sonnenuntergang",
"label_daylight": "Tageslicht",
"label_uv_index": "UV-Index",
"label_moonrise": "Mondaufgang",
"label_moonset": "Monduntergang",
"label_moon": "Mond",
"daylight_hours": "{hours} Std. {minutes} Min.",
"sunshine_share": "{percent}% Sonne",
"uv_low": "Niedrig",
"uv_moderate": "Mäßig",
"uv_high": "Hoch",
"uv_very_high": "Sehr hoch",
"uv_extreme": "Extrem",
"moon_new": "Neumond",
"moon_waxing_crescent": "Zunehmende Sichel",
"moon_first_quarter": "Erstes Viertel",
"moon_waxing_gibbous": "Zunehmender Mond",
"moon_full": "Vollmond",
"moon_waning_gibbous": "Abnehmender Mond",
"moon_last_quarter": "Letztes Viertel",
"moon_waning_crescent": "Abnehmende Sichel",
"cond_clear": "klar",
"cond_fair": "teils bewölkt",
"cond_cloudy": "bedeckt",
"cond_fog": "neblig",
"cond_drizzle": "nieselig",
"cond_rain": "regnerisch",
"cond_snow": "schneereich",
"cond_thunder": "stürmisch",
"period_overnight": "nachts",
"period_morning": "morgens",
"period_afternoon": "nachmittags",
"period_evening": "in den Abendstunden",
"forecast_sky_all_day": "Den ganzen Tag {condition}.",
"forecast_sky_two": "{p1} {c1}, {p2} {c2}.",
"forecast_sky_three": "{p1} {c1}, {p2} {c2}, {p3} dann {c3}.",
"forecast_temp": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
"forecast_temp_feels": "Höchstwerte um {high}, nachts Abkühlung auf {low} - gefühlt eher {feels}.",
"forecast_precip_window": "Rund {amount} Niederschlag, das meiste davon {when}.",
"forecast_precip_spread": "Rund {amount} Niederschlag über den Tag verteilt.",
"forecast_precip_chance": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
"forecast_precip_dry": "Es bleibt durchweg trocken.",
"forecast_wind_dir": "Wind aus {direction} mit bis zu {speed}.",
"forecast_wind_dir_gusts": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
"forecast_wind": "Wind mit bis zu {speed}.",
"forecast_wind_gusts": "Wind mit bis zu {speed}, in Böen {gust}.",
"forecast_uv": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
"forecast_calm": "Kaum ein Lüftchen.",
"footer_tagline": "Schnelle, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
"footer_data_by": "Wetterdaten von",
"footer_forecasts": "Vorhersagen",
"footer_popular": "Beliebte Orte",
"footer_about_section": "Über",
"model_weather": "Wettermodell",
"model_ensemble": "Ensemblemodell"
}
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Day Forecast",
"nav_compare": "Model Comparison",
"nav_14day": "14-Day Forecast",
"nav_seasonal": "Seasonal",
"nav_historical": "Historical",
"nav_maps": "Maps",
"nav_collapse": "Collapse",
"nav_expand_sidebar": "Expand sidebar",
"nav_collapse_sidebar": "Collapse sidebar",
"nav_home": "Drizzli home",
"nav_toggle_menu": "Toggle menu",
"search_placeholder": "Search location...",
"settings_title": "Settings",
"units_title": "Units",
"units_aria": "Choose measurement units",
"unit_temperature": "Temperature",
"unit_wind_speed": "Wind speed",
"unit_precipitation": "Precipitation",
"theme_label": "Theme",
"theme_system": "System",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_follow_system": "Theme: follow system",
"theme_light_title": "Theme: light",
"theme_dark_title": "Theme: dark",
"language_label": "Language",
"supporter_active": "Supporter extras active",
"supporter_support": "Support Drizz.li",
"supporter_manage_key": "Manage your access key",
"supporter_unlock": "Unlock the supporter extras",
"supporter_badge": "Supporter",
"strip_past_label": "past",
"strip_past_aria": "Load the past 3 days",
"strip_days_label": "days",
"strip_extend_aria": "Show the full 15-day forecast",
"strip_history_label": "history",
"strip_history_aria": "Open the historical weather archive",
"strip_history_title": "Historical weather: any past date back to 1940",
"strip_seasonal_label": "seasonal",
"strip_seasonal_aria": "Open the seasonal outlook",
"strip_seasonal_title": "Seasonal outlook: monthly trends for the months ahead",
"day_today": "Today",
"day_tomorrow": "Tomorrow",
"day_yesterday": "Yesterday",
"page_week_subtitle": "7-day forecast",
"page_compare_subtitle": "Model comparison",
"page_14day_subtitle": "14-day ensemble forecast",
"page_seasonal_subtitle": "Seasonal outlook",
"page_historical_subtitle": "Historical weather",
"hourly_heading": "hourly",
"hourly_variables": "Variables",
"meteograms_heading": "Meteograms",
"meteograms_customize": "Customize",
"meteograms_zoom_hint": "drag or",
"meteograms_zoom_hint_end": "+ scroll to zoom",
"range_today": "Today",
"range_selected_day": "Selected day",
"range_3_days": "3 days",
"range_5_days": "5 days",
"range_all": "All",
"range_group_aria": "Chart time range",
"reset_zoom": "Reset zoom",
"default_range_title": "Default time range",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 days on phones, everything on wider screens",
"summary_heading": "in words",
"summary_no_data": "No hourly detail available for this day.",
"label_sunrise": "Sunrise",
"label_sunset": "Sunset",
"label_daylight": "Daylight",
"label_uv_index": "UV index",
"label_moonrise": "Moonrise",
"label_moonset": "Moonset",
"label_moon": "Moon",
"daylight_hours": "{hours} h {minutes} m",
"sunshine_share": "{percent}% sun",
"uv_low": "Low",
"uv_moderate": "Moderate",
"uv_high": "High",
"uv_very_high": "Very high",
"uv_extreme": "Extreme",
"moon_new": "New moon",
"moon_waxing_crescent": "Waxing crescent",
"moon_first_quarter": "First quarter",
"moon_waxing_gibbous": "Waxing gibbous",
"moon_full": "Full moon",
"moon_waning_gibbous": "Waning gibbous",
"moon_last_quarter": "Last quarter",
"moon_waning_crescent": "Waning crescent",
"cond_clear": "clear",
"cond_fair": "partly cloudy",
"cond_cloudy": "overcast",
"cond_fog": "foggy",
"cond_drizzle": "drizzly",
"cond_rain": "wet",
"cond_snow": "snowy",
"cond_thunder": "stormy",
"period_overnight": "overnight",
"period_morning": "in the morning",
"period_afternoon": "in the afternoon",
"period_evening": "in the evening",
"forecast_sky_all_day": "{condition} all day.",
"forecast_sky_two": "{c1} {p1}, turning {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
"forecast_temp": "Highs near {high}, dropping to {low} overnight.",
"forecast_temp_feels": "Highs near {high}, dropping to {low} overnight, though it will feel closer to {feels}.",
"forecast_precip_window": "Around {amount} of precipitation, most of it {when}.",
"forecast_precip_spread": "Around {amount} of precipitation spread across the day.",
"forecast_precip_chance": "Mostly dry, with up to a {percent}% chance of catching a shower.",
"forecast_precip_dry": "Staying dry throughout.",
"forecast_wind_dir": "Wind from the {direction} reaching {speed}.",
"forecast_wind_dir_gusts": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
"forecast_wind": "Wind reaching {speed}.",
"forecast_wind_gusts": "Wind reaching {speed}, with gusts to {gust}.",
"forecast_uv": "UV peaks at {value} ({label}), so cover up around midday.",
"forecast_calm": "Barely a breath of wind.",
"footer_tagline": "Fast, no-nonsense weather forecasts built on open data.",
"footer_data_by": "Weather data by",
"footer_forecasts": "Forecasts",
"footer_popular": "Popular locations",
"footer_about_section": "About",
"model_weather": "Weather model",
"model_ensemble": "Ensemble model"
}
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Pronóstico de 7 días",
"nav_compare": "Comparación de modelos",
"nav_14day": "Pronóstico de 14 días",
"nav_seasonal": "Estacional",
"nav_historical": "Histórico",
"nav_maps": "Mapas",
"nav_collapse": "Contraer",
"nav_expand_sidebar": "Expandir barra lateral",
"nav_collapse_sidebar": "Contraer barra lateral",
"nav_home": "Inicio de Drizzli",
"nav_toggle_menu": "Alternar menú",
"search_placeholder": "Buscar ubicación…",
"settings_title": "Ajustes",
"units_title": "Unidades",
"units_aria": "Elegir unidades de medida",
"unit_temperature": "Temperatura",
"unit_wind_speed": "Velocidad del viento",
"unit_precipitation": "Precipitación",
"theme_label": "Tema",
"theme_system": "Sistema",
"theme_light": "Claro",
"theme_dark": "Oscuro",
"theme_follow_system": "Tema: seguir al sistema",
"theme_light_title": "Tema: claro",
"theme_dark_title": "Tema: oscuro",
"language_label": "Idioma",
"supporter_active": "Extras de colaborador activos",
"supporter_support": "Apoya Drizz.li",
"supporter_manage_key": "Gestiona tu clave de acceso",
"supporter_unlock": "Desbloquea los extras de colaborador",
"supporter_badge": "Colaborador",
"strip_past_label": "antes",
"strip_past_aria": "Cargar los últimos 3 días",
"strip_days_label": "días",
"strip_extend_aria": "Ver el pronóstico completo de 15 días",
"strip_history_label": "archivo",
"strip_history_aria": "Abrir el archivo meteorológico",
"strip_history_title": "Histórico: cualquier fecha desde 1940",
"strip_seasonal_label": "estacional",
"strip_seasonal_aria": "Abrir la previsión estacional",
"strip_seasonal_title": "Previsión estacional: tendencias de los próximos meses",
"day_today": "Hoy",
"day_tomorrow": "Mañana",
"day_yesterday": "Ayer",
"page_week_subtitle": "pronóstico de 7 días",
"page_compare_subtitle": "comparación de modelos",
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "previsión estacional",
"page_historical_subtitle": "clima histórico",
"hourly_heading": "por horas",
"hourly_variables": "Variables",
"meteograms_heading": "Meteogramas",
"meteograms_customize": "Personalizar",
"meteograms_zoom_hint": "arrastra o",
"meteograms_zoom_hint_end": "+ desplazar para ampliar",
"range_today": "Hoy",
"range_selected_day": "Día seleccionado",
"range_3_days": "3 días",
"range_5_days": "5 días",
"range_all": "Todo",
"range_group_aria": "Rango temporal del gráfico",
"reset_zoom": "Restablecer zoom",
"default_range_title": "Rango temporal predeterminado",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 días en el móvil, todo en pantallas anchas",
"summary_heading": "en palabras",
"summary_no_data": "No hay datos horarios para este día.",
"label_sunrise": "Amanecer",
"label_sunset": "Atardecer",
"label_daylight": "Luz diurna",
"label_uv_index": "Índice UV",
"label_moonrise": "Salida de la luna",
"label_moonset": "Puesta de la luna",
"label_moon": "Luna",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% de sol",
"uv_low": "Bajo",
"uv_moderate": "Moderado",
"uv_high": "Alto",
"uv_very_high": "Muy alto",
"uv_extreme": "Extremo",
"moon_new": "Luna nueva",
"moon_waxing_crescent": "Luna creciente",
"moon_first_quarter": "Cuarto creciente",
"moon_waxing_gibbous": "Gibosa creciente",
"moon_full": "Luna llena",
"moon_waning_gibbous": "Gibosa menguante",
"moon_last_quarter": "Cuarto menguante",
"moon_waning_crescent": "Luna menguante",
"cond_clear": "despejado",
"cond_fair": "parcialmente nublado",
"cond_cloudy": "nublado",
"cond_fog": "con niebla",
"cond_drizzle": "con llovizna",
"cond_rain": "lluvioso",
"cond_snow": "con nieve",
"cond_thunder": "tormentoso",
"period_overnight": "por la noche",
"period_morning": "por la mañana",
"period_afternoon": "por la tarde",
"period_evening": "al anochecer",
"forecast_sky_all_day": "{condition} todo el día.",
"forecast_sky_two": "{c1} {p1} y {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
"forecast_temp": "Máximas de {high} y mínimas de {low} por la noche.",
"forecast_temp_feels": "Máximas de {high} y mínimas de {low} por la noche, aunque la sensación será de {feels}.",
"forecast_precip_window": "Unos {amount} de precipitación, sobre todo {when}.",
"forecast_precip_spread": "Unos {amount} de precipitación repartidos a lo largo del día.",
"forecast_precip_chance": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
"forecast_precip_dry": "Se mantendrá seco todo el día.",
"forecast_wind_dir": "Viento del {direction} de hasta {speed}.",
"forecast_wind_dir_gusts": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
"forecast_wind": "Viento de hasta {speed}.",
"forecast_wind_gusts": "Viento de hasta {speed}, con rachas de {gust}.",
"forecast_uv": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
"forecast_calm": "Apenas hay viento.",
"footer_tagline": "Pronósticos rápidos y directos basados en datos abiertos.",
"footer_data_by": "Datos meteorológicos de",
"footer_forecasts": "Pronósticos",
"footer_popular": "Ubicaciones populares",
"footer_about_section": "Acerca de",
"model_weather": "Modelo meteorológico",
"model_ensemble": "Modelo de conjunto"
}
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Prévisions à 7 jours",
"nav_compare": "Comparaison des modèles",
"nav_14day": "Prévisions à 14 jours",
"nav_seasonal": "Saisonnier",
"nav_historical": "Historique",
"nav_maps": "Cartes",
"nav_collapse": "Réduire",
"nav_expand_sidebar": "Déployer la barre latérale",
"nav_collapse_sidebar": "Réduire la barre latérale",
"nav_home": "Accueil Drizzli",
"nav_toggle_menu": "Basculer le menu",
"search_placeholder": "Rechercher un lieu…",
"settings_title": "Paramètres",
"units_title": "Unités",
"units_aria": "Choisir les unités de mesure",
"unit_temperature": "Température",
"unit_wind_speed": "Vitesse du vent",
"unit_precipitation": "Précipitations",
"theme_label": "Thème",
"theme_system": "Système",
"theme_light": "Clair",
"theme_dark": "Sombre",
"theme_follow_system": "Thème : suivre le système",
"theme_light_title": "Thème : clair",
"theme_dark_title": "Thème : sombre",
"language_label": "Langue",
"supporter_active": "Bonus contributeur actifs",
"supporter_support": "Soutenir Drizz.li",
"supporter_manage_key": "Gérer votre clé d'accès",
"supporter_unlock": "Débloquer les bonus contributeur",
"supporter_badge": "Contributeur",
"strip_past_label": "avant",
"strip_past_aria": "Charger les 3 derniers jours",
"strip_days_label": "jours",
"strip_extend_aria": "Afficher les 15 jours complets",
"strip_history_label": "archives",
"strip_history_aria": "Ouvrir les archives météo",
"strip_history_title": "Historique : toute date depuis 1940",
"strip_seasonal_label": "saisonnier",
"strip_seasonal_aria": "Ouvrir les tendances saisonnières",
"strip_seasonal_title": "Tendances saisonnières : les mois à venir",
"day_today": "Aujourd'hui",
"day_tomorrow": "Demain",
"day_yesterday": "Hier",
"page_week_subtitle": "prévisions à 7 jours",
"page_compare_subtitle": "comparaison des modèles",
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "tendances saisonnières",
"page_historical_subtitle": "météo historique",
"hourly_heading": "par heure",
"hourly_variables": "Variables",
"meteograms_heading": "Météogrammes",
"meteograms_customize": "Personnaliser",
"meteograms_zoom_hint": "glissez ou",
"meteograms_zoom_hint_end": "+ défiler pour zoomer",
"range_today": "Aujourd'hui",
"range_selected_day": "Jour sélectionné",
"range_3_days": "3 jours",
"range_5_days": "5 jours",
"range_all": "Tout",
"range_group_aria": "Plage horaire du graphique",
"reset_zoom": "Réinitialiser le zoom",
"default_range_title": "Plage horaire par défaut",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 jours sur mobile, tout sur grand écran",
"summary_heading": "en résumé",
"summary_no_data": "Aucun détail horaire pour ce jour.",
"label_sunrise": "Lever du soleil",
"label_sunset": "Coucher du soleil",
"label_daylight": "Durée du jour",
"label_uv_index": "Indice UV",
"label_moonrise": "Lever de la lune",
"label_moonset": "Coucher de la lune",
"label_moon": "Lune",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% de soleil",
"uv_low": "Faible",
"uv_moderate": "Modéré",
"uv_high": "Élevé",
"uv_very_high": "Très élevé",
"uv_extreme": "Extrême",
"moon_new": "Nouvelle lune",
"moon_waxing_crescent": "Premier croissant",
"moon_first_quarter": "Premier quartier",
"moon_waxing_gibbous": "Gibbeuse croissante",
"moon_full": "Pleine lune",
"moon_waning_gibbous": "Gibbeuse décroissante",
"moon_last_quarter": "Dernier quartier",
"moon_waning_crescent": "Dernier croissant",
"cond_clear": "dégagé",
"cond_fair": "partiellement nuageux",
"cond_cloudy": "couvert",
"cond_fog": "brumeux",
"cond_drizzle": "bruineux",
"cond_rain": "pluvieux",
"cond_snow": "neigeux",
"cond_thunder": "orageux",
"period_overnight": "la nuit",
"period_morning": "le matin",
"period_afternoon": "l'après-midi",
"period_evening": "en soirée",
"forecast_sky_all_day": "{condition} toute la journée.",
"forecast_sky_two": "{c1} {p1}, puis {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
"forecast_temp": "Maximales proches de {high}, minimales de {low} la nuit.",
"forecast_temp_feels": "Maximales proches de {high}, minimales de {low} la nuit, mais ressenti plutôt {feels}.",
"forecast_precip_window": "Environ {amount} de précipitations, surtout {when}.",
"forecast_precip_spread": "Environ {amount} de précipitations réparties sur la journée.",
"forecast_precip_chance": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
"forecast_precip_dry": "Le temps reste sec toute la journée.",
"forecast_wind_dir": "Vent de {direction} atteignant {speed}.",
"forecast_wind_dir_gusts": "Vent de {direction} atteignant {speed}, avec des rafales à {gust}.",
"forecast_wind": "Vent atteignant {speed}.",
"forecast_wind_gusts": "Vent atteignant {speed}, avec des rafales à {gust}.",
"forecast_uv": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
"forecast_calm": "Pratiquement pas de vent.",
"footer_tagline": "Des prévisions météo rapides et sans fioritures, basées sur des données ouvertes.",
"footer_data_by": "Données météo par",
"footer_forecasts": "Prévisions",
"footer_popular": "Lieux populaires",
"footer_about_section": "À propos",
"model_weather": "Modèle météo",
"model_ensemble": "Modèle d'ensemble"
}
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni a 7 giorni",
"nav_compare": "Confronto modelli",
"nav_14day": "Previsioni a 14 giorni",
"nav_seasonal": "Stagionale",
"nav_historical": "Storico",
"nav_maps": "Mappe",
"nav_collapse": "Comprimi",
"nav_expand_sidebar": "Espandi barra laterale",
"nav_collapse_sidebar": "Comprimi barra laterale",
"nav_home": "Home di Drizzli",
"nav_toggle_menu": "Attiva/disattiva menu",
"search_placeholder": "Cerca una località…",
"settings_title": "Impostazioni",
"units_title": "Unità",
"units_aria": "Scegli le unità di misura",
"unit_temperature": "Temperatura",
"unit_wind_speed": "Velocità del vento",
"unit_precipitation": "Precipitazioni",
"theme_label": "Tema",
"theme_system": "Sistema",
"theme_light": "Chiaro",
"theme_dark": "Scuro",
"theme_follow_system": "Tema: segui il sistema",
"theme_light_title": "Tema: chiaro",
"theme_dark_title": "Tema: scuro",
"language_label": "Lingua",
"supporter_active": "Extra sostenitore attivi",
"supporter_support": "Sostieni Drizz.li",
"supporter_manage_key": "Gestisci la tua chiave di accesso",
"supporter_unlock": "Sblocca gli extra sostenitore",
"supporter_badge": "Sostenitore",
"strip_past_label": "prima",
"strip_past_aria": "Carica gli ultimi 3 giorni",
"strip_days_label": "giorni",
"strip_extend_aria": "Mostra le previsioni complete a 15 giorni",
"strip_history_label": "archivio",
"strip_history_aria": "Apri l'archivio meteo",
"strip_history_title": "Storico: qualsiasi data dal 1940",
"strip_seasonal_label": "stagionale",
"strip_seasonal_aria": "Apri le tendenze stagionali",
"strip_seasonal_title": "Tendenze stagionali: i prossimi mesi",
"day_today": "Oggi",
"day_tomorrow": "Domani",
"day_yesterday": "Ieri",
"page_week_subtitle": "previsioni a 7 giorni",
"page_compare_subtitle": "confronto modelli",
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "tendenze stagionali",
"page_historical_subtitle": "meteo storico",
"hourly_heading": "orario",
"hourly_variables": "Variabili",
"meteograms_heading": "Meteogrammi",
"meteograms_customize": "Personalizza",
"meteograms_zoom_hint": "trascina o",
"meteograms_zoom_hint_end": "+ scorri per ingrandire",
"range_today": "Oggi",
"range_selected_day": "Giorno selezionato",
"range_3_days": "3 giorni",
"range_5_days": "5 giorni",
"range_all": "Tutto",
"range_group_aria": "Intervallo temporale del grafico",
"reset_zoom": "Reimposta zoom",
"default_range_title": "Intervallo temporale predefinito",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 giorni su cellulare, tutto su schermi ampi",
"summary_heading": "in parole",
"summary_no_data": "Nessun dato orario per questo giorno.",
"label_sunrise": "Alba",
"label_sunset": "Tramonto",
"label_daylight": "Ore di luce",
"label_uv_index": "Indice UV",
"label_moonrise": "Sorgere della luna",
"label_moonset": "Tramonto della luna",
"label_moon": "Luna",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% di sole",
"uv_low": "Basso",
"uv_moderate": "Moderato",
"uv_high": "Alto",
"uv_very_high": "Molto alto",
"uv_extreme": "Estremo",
"moon_new": "Luna nuova",
"moon_waxing_crescent": "Luna crescente",
"moon_first_quarter": "Primo quarto",
"moon_waxing_gibbous": "Gibbosa crescente",
"moon_full": "Luna piena",
"moon_waning_gibbous": "Gibbosa calante",
"moon_last_quarter": "Ultimo quarto",
"moon_waning_crescent": "Luna calante",
"cond_clear": "sereno",
"cond_fair": "parzialmente nuvoloso",
"cond_cloudy": "coperto",
"cond_fog": "nebbioso",
"cond_drizzle": "con pioviggine",
"cond_rain": "piovoso",
"cond_snow": "nevoso",
"cond_thunder": "temporalesco",
"period_overnight": "durante la notte",
"period_morning": "al mattino",
"period_afternoon": "nel pomeriggio",
"period_evening": "in serata",
"forecast_sky_all_day": "{condition} per tutta la giornata.",
"forecast_sky_two": "{c1} {p1}, poi {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
"forecast_temp": "Massime intorno a {high} e minime di {low} durante la notte.",
"forecast_temp_feels": "Massime intorno a {high} e minime di {low} durante la notte, ma con una percezione di {feels}.",
"forecast_precip_window": "Circa {amount} di precipitazioni, soprattutto {when}.",
"forecast_precip_spread": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
"forecast_precip_chance": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
"forecast_precip_dry": "Resterà asciutto tutto il giorno.",
"forecast_wind_dir": "Vento da {direction} fino a {speed}.",
"forecast_wind_dir_gusts": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
"forecast_wind": "Vento fino a {speed}.",
"forecast_wind_gusts": "Vento fino a {speed}, con raffiche a {gust}.",
"forecast_uv": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
"forecast_calm": "Quasi assenza di vento.",
"footer_tagline": "Previsioni meteo rapide e senza fronzoli, basate su dati aperti.",
"footer_data_by": "Dati meteo da",
"footer_forecasts": "Previsioni",
"footer_popular": "Località popolari",
"footer_about_section": "Informazioni",
"model_weather": "Modello meteo",
"model_ensemble": "Modello d'insieme"
}
+268
View File
@@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0", "@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -495,6 +496,64 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@inlang/paraglide-js": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.23.0.tgz",
"integrity": "sha512-q+FQisRAVQqyD+0fdVHPkC7UDfaXENUJqUjim7XGEzWHE6vVMUOO10MFtAOKx5VYAgi8o7rLwe4zjzesCY0Anw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "^0.2.1",
"@inlang/sdk": "^2.10.0",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
"unplugin": "^2.1.2",
"urlpattern-polyfill": "^10.0.0"
},
"bin": {
"paraglide-js": "bin/run.js"
},
"peerDependencies": {
"typescript": ">=5.6",
"vite": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.3"
}
},
"node_modules/@inlang/sdk": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.10.2.tgz",
"integrity": "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.10",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^14.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.12.2", "version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
@@ -555,6 +614,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lix-js/sdk": {
"version": "0.4.10",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz",
"integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-protocol-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@lucide/svelte": { "node_modules/@lucide/svelte": {
"version": "1.25.0", "version": "1.25.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
@@ -896,6 +981,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sinclair/typebox": {
"version": "0.31.30",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz",
"integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.48.0-build4",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1896,6 +1998,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1994,6 +2103,40 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/comment-json": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2078,6 +2221,21 @@
} }
} }
}, },
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2374,6 +2532,20 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2603,6 +2775,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/human-id": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz",
"integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==",
"dev": true,
"license": "MIT",
"bin": {
"human-id": "dist/cli.js"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2687,6 +2869,13 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2728,6 +2917,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2755,6 +2957,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/kysely": {
"version": "0.28.17",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3779,6 +3991,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sqlite-wasm-kysely": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
"dev": true,
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
},
"peerDependencies": {
"kysely": "*"
}
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4170,6 +4394,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4180,6 +4420,13 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/urlpattern-polyfill": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4187,6 +4434,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.5", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -4407,6 +4668,13 @@
"vitest": "^4.0.0" "vitest": "^4.0.0"
} }
}, },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -19,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0", "@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "es", "fr", "it"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2.2.6/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="%paraglide.lang%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+16
View File
@@ -0,0 +1,16 @@
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle } from '@sveltejs/kit';
/**
* Runs for every prerendered page, so the HTML written to disk is already in the
* right language and carries a matching `<html lang>` - the client never has to
* repaint the page into its locale.
*/
export const handle: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
+10
View File
@@ -0,0 +1,10 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
import type { Reroute } from '@sveltejs/kit';
/**
* The locale lives in the URL (`/de/weather/week/`), but the routes on disk are
* language-neutral. Stripping the prefix here lets one route tree serve every
* language - on the server, during prerendering and on client-side navigation.
*/
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
@@ -0,0 +1,53 @@
<script lang="ts">
import { page } from '$app/stores';
import { LOCALE_LABELS, LOCALE_LIST } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { type Locale, getLocale, localizeHref } from '$lib/paraglide/runtime';
interface Props {
/** Called after a language is picked (used to close the menu around it). */
onSelect?: () => void;
}
let { onSelect }: Props = $props();
let current = $derived.by(() => {
// re-read on navigation: the URL is what decides the locale
void $page.url.pathname;
return getLocale();
});
/** The current page, in another language. */
function switchTo(locale: Locale): string {
return localizeHref($page.url.pathname + $page.url.search, { locale });
}
</script>
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{m.language_label()}
</span>
<!-- Plain links, not buttons: the locale lives in the URL, so switching
language is a navigation. It also means each option is shareable and
crawlable. -->
<div class="grid grid-cols-2 gap-1">
{#each LOCALE_LIST as locale (locale)}
{@const active = current === locale}
<a
href={switchTo(locale)}
hreflang={locale}
data-sveltekit-reload
class="cursor-pointer rounded-md px-2 py-1.5 text-center text-[13px] font-semibold transition-colors {active
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
aria-current={active ? 'true' : undefined}
onclick={onSelect}
>
{LOCALE_LABELS[locale]}
</a>
{/each}
</div>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { page } from '$app/stores';
// the URL decides the locale, so re-read it on navigation
let current = $derived.by(() => {
void $page.url.pathname;
return getLocale();
});
let open = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label={m.language_label()}
title={m.language_label()}
>
<!-- globe -->
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18Z" />
</svg>
<span class="uppercase">{current}</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-56 border-border">
<LanguageOptions onSelect={() => (open = false)} />
</Popover.Content>
</Popover.Root>
+19 -18
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import LogoMark from './logo-mark.svelte'; import LogoMark from './logo-mark.svelte';
@@ -22,12 +23,12 @@
]; ];
const forecastLinks = [ const forecastLinks = [
{ href: resolve('/weather/week'), label: '7-Day Forecast' }, { href: href('/weather/week'), label: m.nav_week() },
{ href: resolve('/weather/compare'), label: 'Model Comparison' }, { href: href('/weather/compare'), label: m.nav_compare() },
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' }, { href: href('/weather/14-day'), label: m.nav_14day() },
{ href: resolve('/weather/seasonal'), label: 'Seasonal Outlook' }, { href: href('/weather/seasonal'), label: m.page_seasonal_subtitle() },
{ href: resolve('/weather/historical'), label: 'Historical Weather' }, { href: href('/weather/historical'), label: m.page_historical_subtitle() },
{ href: resolve('/weather/maps'), label: 'Weather Maps' } { href: href('/weather/maps'), label: m.nav_maps() }
]; ];
const year = new Date().getFullYear(); const year = new Date().getFullYear();
@@ -47,10 +48,10 @@
<span class="text-lg font-bold tracking-tight">Drizz.li</span> <span class="text-lg font-bold tracking-tight">Drizz.li</span>
</div> </div>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground"> <p class="mt-3 text-sm leading-relaxed text-muted-foreground">
Fast, no-nonsense weather forecasts built on open data. {m.footer_tagline()}
</p> </p>
<p class="mt-3 text-xs text-muted-foreground"> <p class="mt-3 text-xs text-muted-foreground">
Weather data by {m.footer_data_by()}
<a <a
class="font-medium underline-offset-2 hover:text-foreground hover:underline" class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com" href="https://open-meteo.com"
@@ -61,9 +62,9 @@
</div> </div>
<!-- Forecasts --> <!-- Forecasts -->
<nav aria-label="Forecasts"> <nav aria-label={m.footer_forecasts()}>
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase"> <h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Forecasts {m.footer_forecasts()}
</h3> </h3>
<ul class="mt-3 flex flex-col gap-2 text-sm"> <ul class="mt-3 flex flex-col gap-2 text-sm">
{#each forecastLinks as link (link.href)} {#each forecastLinks as link (link.href)}
@@ -78,16 +79,16 @@
</nav> </nav>
<!-- Popular locations (spans two columns of links) --> <!-- Popular locations (spans two columns of links) -->
<nav aria-label="Popular locations" class="lg:col-span-2"> <nav aria-label={m.footer_popular()} class="lg:col-span-2">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase"> <h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Popular locations {m.footer_popular()}
</h3> </h3>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3"> <ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{#each popularCities as city (city.slug)} {#each popularCities as city (city.slug)}
<li> <li>
<a <a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline" class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/weather/week/[location]', { location: city.slug })} href={href('/weather/week/[location]', { location: city.slug })}
>{city.label} weather</a >{city.label} weather</a
> >
</li> </li>
@@ -102,20 +103,20 @@
> >
<span>© {year} Drizz.li</span> <span>© {year} Drizz.li</span>
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1"> <nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1">
<a class="underline-offset-2 hover:text-foreground hover:underline" href={resolve('/about')} <a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')}
>About</a >About</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/imprint')}>Imprint</a href={href('/legal/imprint')}>Imprint</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/privacy')}>Privacy</a href={href('/legal/privacy')}>Privacy</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/terms')}>Terms</a href={href('/legal/terms')}>Terms</a
> >
</nav> </nav>
</div> </div>
+20 -15
View File
@@ -2,16 +2,18 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings'; import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import LanguageSelector from '$lib/components/language-selector.svelte';
import LocationSearch from '$lib/components/location/location-search.svelte'; import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte'; import UnitSelector from '$lib/components/unit-selector.svelte';
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte'; import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SettingsMenu from './settings-menu.svelte'; import SettingsMenu from './settings-menu.svelte';
@@ -40,10 +42,10 @@
}); });
const themeCycle: Theme[] = ['system', 'light', 'dark']; const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, string> = { const themeTitles: Record<Theme, () => string> = {
system: 'Theme: follow system', system: m.theme_follow_system,
light: 'Theme: light', light: m.theme_light_title,
dark: 'Theme: dark' dark: m.theme_dark_title
}; };
function cycleTheme() { function cycleTheme() {
@@ -55,18 +57,18 @@
function navigateToLocation(newLocation: GeoLocation) { function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation); storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation); const locationRoute = buildLocationRoute(newLocation);
const currentPath = get(page).url.pathname; const currentPath = routePath(get(page).url.pathname);
if (currentPath.startsWith('/weather/compare')) { if (currentPath.startsWith('/weather/compare')) {
goto(resolve('/weather/compare/[location]', { location: locationRoute })); goto(href('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) { } else if (currentPath.startsWith('/weather/14-day')) {
goto(resolve('/weather/14-day/[location]', { location: locationRoute })); goto(href('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) { } else if (currentPath.startsWith('/weather/historical')) {
goto(resolve('/weather/historical/[location]', { location: locationRoute })); goto(href('/weather/historical/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/seasonal')) { } else if (currentPath.startsWith('/weather/seasonal')) {
goto(resolve('/weather/seasonal/[location]', { location: locationRoute })); goto(href('/weather/seasonal/[location]', { location: locationRoute }));
} else { } else {
goto(resolve('/weather/week/[location]', { location: locationRoute })); goto(href('/weather/week/[location]', { location: locationRoute }));
} }
} }
</script> </script>
@@ -81,7 +83,7 @@
<button <button
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden" class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onclick={onMenuToggle} onclick={onMenuToggle}
aria-label="Toggle menu" aria-label={m.nav_toggle_menu()}
> >
<svg <svg
class="h-5 w-5" class="h-5 w-5"
@@ -125,7 +127,7 @@
<!-- Location search: primary way to switch places, so keep it loud --> <!-- Location search: primary way to switch places, so keep it loud -->
<div class="w-full max-w-sm md:max-w-md"> <div class="w-full max-w-sm md:max-w-md">
<LocationSearch <LocationSearch
label="Search location..." label={m.search_placeholder()}
on:location={(event) => { on:location={(event) => {
navigateToLocation(event.detail); navigateToLocation(event.detail);
}} }}
@@ -142,6 +144,9 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`) <!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. --> so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex"> <div class="hidden items-center gap-3 md:flex">
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
<!-- Measurement units --> <!-- Measurement units -->
<UnitSelector /> <UnitSelector />
@@ -152,8 +157,8 @@
<button <button
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={cycleTheme} onclick={cycleTheme}
title={themeTitles[$storedTheme]} title={themeTitles[$storedTheme]()}
aria-label={themeTitles[$storedTheme]} aria-label={themeTitles[$storedTheme]()}
> >
<ThemeIcon theme={$storedTheme} /> <ThemeIcon theme={$storedTheme} />
</button> </button>
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { type Theme, storedTheme } from '$lib/stores/settings'; import { type Theme, storedTheme } from '$lib/stores/settings';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import UnitOptions from '$lib/components/unit-options.svelte'; import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte'; import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter'; import { isSupporter } from '$lib/paywall/supporter';
@@ -11,10 +13,10 @@
// The topbar has room for one control on a phone, so units, theme and the // The topbar has room for one control on a phone, so units, theme and the
// supporter status share this menu instead of each carrying its own trigger. // supporter status share this menu instead of each carrying its own trigger.
const THEMES: { value: Theme; label: string }[] = [ const THEMES: { value: Theme; label: () => string }[] = [
{ value: 'system', label: 'System' }, { value: 'system', label: m.theme_system },
{ value: 'light', label: 'Light' }, { value: 'light', label: m.theme_light },
{ value: 'dark', label: 'Dark' } { value: 'dark', label: m.theme_dark }
]; ];
let open = $state(false); let open = $state(false);
@@ -24,8 +26,8 @@
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger <Popover.Trigger
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground" class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Settings" aria-label={m.settings_title()}
title="Settings" title={m.settings_title()}
> >
<!-- gear --> <!-- gear -->
<svg <svg
@@ -52,7 +54,7 @@
<span <span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase" class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
> >
Theme {m.theme_label()}
</span> </span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5"> <div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each THEMES as option (option.value)} {#each THEMES as option (option.value)}
@@ -65,12 +67,14 @@
onclick={() => storedTheme.set(option.value)} onclick={() => storedTheme.set(option.value)}
> >
<ThemeIcon theme={option.value} class="h-4 w-4" /> <ThemeIcon theme={option.value} class="h-4 w-4" />
{option.label} {option.label()}
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
<LanguageOptions onSelect={() => (open = false)} />
<div class="border-t border-border/70 pt-3"> <div class="border-t border-border/70 pt-3">
<button <button
type="button" type="button"
@@ -108,10 +112,10 @@
{/if} {/if}
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold"> <span class="block text-[13px] font-semibold">
{$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'} {$isSupporter ? m.supporter_active() : m.supporter_support()}
</span> </span>
<span class="block text-[11px] text-muted-foreground"> <span class="block text-[11px] text-muted-foreground">
{$isSupporter ? 'Manage your access key' : 'Unlock the supporter extras'} {$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span> </span>
</span> </span>
</button> </button>
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import LogoMark from './logo-mark.svelte'; import LogoMark from './logo-mark.svelte';
interface Props { interface Props {
@@ -14,38 +16,38 @@
const links = [ const links = [
{ {
title: '7-Day Forecast', title: m.nav_week,
url: '/weather/week' as const, url: '/weather/week' as const,
iconPaths: [ iconPaths: [
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z' 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
] ]
}, },
{ {
title: 'Model Comparison', title: m.nav_compare,
url: '/weather/compare' as const, url: '/weather/compare' as const,
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6'] iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
}, },
{ {
title: '14-Day Forecast', title: m.nav_14day,
url: '/weather/14-day' as const, url: '/weather/14-day' as const,
iconPaths: [ iconPaths: [
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z' 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
] ]
}, },
{ {
title: 'Seasonal', title: m.nav_seasonal,
url: '/weather/seasonal' as const, url: '/weather/seasonal' as const,
// rising trend line (long-range outlook) // rising trend line (long-range outlook)
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5'] iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
}, },
{ {
title: 'Historical', title: m.nav_historical,
url: '/weather/historical' as const, url: '/weather/historical' as const,
// clock with a counter-clockwise arrow (history) // clock with a counter-clockwise arrow (history)
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5'] iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
}, },
{ {
title: 'Maps', title: m.nav_maps,
url: '/weather/maps' as const, url: '/weather/maps' as const,
// Heroicons "map" outline icon // Heroicons "map" outline icon
iconPaths: [ iconPaths: [
@@ -54,7 +56,8 @@
} }
]; ];
let currentPath = $derived($page.url.pathname); // the URL carries a locale prefix; compare the neutral path behind it
let currentPath = $derived(routePath($page.url.pathname));
const isActive = (url: string) => { const isActive = (url: string) => {
return currentPath === url || currentPath.startsWith(url + '/'); return currentPath === url || currentPath.startsWith(url + '/');
@@ -70,12 +73,12 @@
home link fills the entire row, padding included --> home link fills the entire row, padding included -->
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border"> <div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
<a <a
href={resolve('/weather/week')} href={href('/weather/week')}
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
? 'justify-center' ? 'justify-center'
: 'px-4'}" : 'px-4'}"
onclick={onMobileClose} onclick={onMobileClose}
aria-label="Drizzli home" aria-label={m.nav_home()}
> >
<div <div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
@@ -92,14 +95,14 @@
<!-- Navigation links --> <!-- Navigation links -->
<nav class="flex-1 space-y-1 px-2 py-3"> <nav class="flex-1 space-y-1 px-2 py-3">
{#each links as link (link.title)} {#each links as link (link.url)}
{@const active = isActive(link.url)} {@const active = isActive(link.url)}
<a <a
href={resolve(link.url)} href={href(link.url)}
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active' ? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
: ''}" : ''}"
title={collapsed ? link.title : undefined} title={collapsed ? link.title() : undefined}
onclick={onMobileClose} onclick={onMobileClose}
> >
<div class="flex h-5 w-5 shrink-0 items-center justify-center"> <div class="flex h-5 w-5 shrink-0 items-center justify-center">
@@ -116,7 +119,7 @@
</svg> </svg>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">{link.title}</span> <span class="ml-2.5 whitespace-nowrap">{link.title()}</span>
{/if} {/if}
</a> </a>
{/each} {/each}
@@ -130,7 +133,7 @@
<button <button
class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100" class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
onclick={onToggle} onclick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} title={collapsed ? m.nav_expand_sidebar() : m.nav_collapse_sidebar()}
> >
<div class="flex h-5 w-5 shrink-0 items-center justify-center"> <div class="flex h-5 w-5 shrink-0 items-center justify-center">
<svg <svg
@@ -149,7 +152,7 @@
</svg> </svg>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">Collapse</span> <span class="ml-2.5 whitespace-nowrap">{m.nav_collapse()}</span>
{/if} {/if}
</button> </button>
</div> </div>
+7 -5
View File
@@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings'; import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
// each group maps a stored unit key to its selectable options // each group maps a stored unit key to its selectable options
const UNIT_GROUPS: { const UNIT_GROUPS: {
key: keyof UnitPrefs; key: keyof UnitPrefs;
label: string; label: () => string;
options: { value: string; label: string }[]; options: { value: string; label: string }[];
}[] = [ }[] = [
{ {
key: 'temperature_unit', key: 'temperature_unit',
label: 'Temperature', label: m.unit_temperature,
options: [ options: [
{ value: 'celsius', label: '°C' }, { value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' } { value: 'fahrenheit', label: '°F' }
@@ -17,7 +19,7 @@
}, },
{ {
key: 'wind_speed_unit', key: 'wind_speed_unit',
label: 'Wind speed', label: m.unit_wind_speed,
options: [ options: [
{ value: 'kmh', label: 'km/h' }, { value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' }, { value: 'ms', label: 'm/s' },
@@ -27,7 +29,7 @@
}, },
{ {
key: 'precipitation_unit', key: 'precipitation_unit',
label: 'Precipitation', label: m.unit_precipitation,
options: [ options: [
{ value: 'mm', label: 'mm' }, { value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' } { value: 'inch', label: 'inch' }
@@ -46,7 +48,7 @@
<span <span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase" class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
> >
{group.label} {group.label()}
</span> </span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5"> <div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)} {#each group.options as opt (opt.value)}
+4 -2
View File
@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import UnitOptions from '$lib/components/unit-options.svelte'; import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
</script> </script>
<Popover.Root> <Popover.Root>
<Popover.Trigger <Popover.Trigger
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground" class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Choose measurement units" aria-label={m.units_aria()}
title="Units" title={m.units_title()}
> >
<!-- gauge icon --> <!-- gauge icon -->
<svg <svg
+81
View File
@@ -0,0 +1,81 @@
/**
* Locale-aware routing helpers.
*
* SvelteKit's `resolve()` returns the language-neutral path a route lives at;
* every link has to go through `localizeHref()` on top of that, or clicking it
* would drop the visitor back into the base locale (the reroute hook in
* `src/hooks.ts` strips the prefix again on the way in).
*/
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { type Locale, deLocalizeHref, locales, localizeHref } from '$lib/paraglide/runtime';
import type {
PathnameWithSearchOrHash,
RouteId,
RouteIdWithSearchOrHash,
RouteParams
} from '$app/types';
// Mirrors SvelteKit's own (non-exported) argument type for `resolve`, so a
// route id still demands exactly the params that route declares.
type StripSearchOrHash<T extends string> = T extends `${infer P}?${string}`
? P
: T extends `${infer P}#${string}`
? P
: T;
type ResolveArgs<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash> = T extends RouteId
? RouteParams<T> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<T>]
: StripSearchOrHash<T> extends infer U extends RouteId
? RouteParams<U> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<U>]
: [route: T];
/**
* `resolve()` for the current locale. Mirrors SvelteKit's own signature, so
* route ids and their params keep being type-checked.
*/
export function href<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
...args: ResolveArgs<T>
): string {
return localizeHref(resolve(...args));
}
/** Same as `href`, but forced into a specific locale. */
export function hrefIn<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
locale: Locale,
...args: ResolveArgs<T>
): string {
return localizeHref(resolve(...args), { locale });
}
/** `goto()` that keeps the visitor in their language. */
export function gotoLocalized<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
...args: ResolveArgs<T>
) {
return goto(href(...args));
}
/**
* The language-neutral path for a URL that still carries its locale prefix -
* what "which page am I on?" checks have to compare against.
*/
export function routePath(pathname: string): string {
return deLocalizeHref(pathname);
}
/** Display names for the language switcher, in the language itself. */
export const LOCALE_LABELS: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano'
};
export const LOCALE_LIST = locales;
+6 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter'; import { isSupporter, refreshSupporter } from './supporter';
@@ -16,8 +18,8 @@
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300' ? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}" : 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => (open = true)} onclick={() => (open = true)}
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'} title={$isSupporter ? m.supporter_active() : m.supporter_support()}
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'} aria-label={$isSupporter ? m.supporter_active() : m.supporter_support()}
> >
{#if $isSupporter} {#if $isSupporter}
<!-- star --> <!-- star -->
@@ -26,7 +28,7 @@
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z" d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/> />
</svg> </svg>
<span class="hidden sm:inline">Supporter</span> <span class="hidden sm:inline">{m.supporter_badge()}</span>
{:else} {:else}
<!-- padlock --> <!-- padlock -->
<svg <svg
@@ -40,7 +42,7 @@
<rect x="4" y="10" width="16" height="11" rx="2" /> <rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" /> <path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg> </svg>
<span class="hidden sm:inline">Supporter</span> <span class="hidden sm:inline">{m.supporter_badge()}</span>
{/if} {/if}
</button> </button>
+17 -5
View File
@@ -1,12 +1,24 @@
import { isSameDay as isSameDayDateFns } from 'date-fns'; import { isSameDay as isSameDayDateFns } from 'date-fns';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
import { de, enGB, es, fr, it } from 'date-fns/locale';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import type { Locale as DateFnsLocale } from 'date-fns';
// Weekday and month names come from date-fns, so they have to follow the same
// locale the messages do.
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
/** /**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns. * Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc. * Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*/ */
export function formatZoned(date: Date, timeZone: string, pattern: string): string { export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern); return formatInTimeZone(date, timeZone, pattern, {
locale: DATE_LOCALES[getLocale()] ?? enGB
});
} }
/** /**
@@ -35,17 +47,17 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
const zonedDate = toZonedTime(date, timeZone); const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone); const zonedNow = toZonedTime(now, timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today'; if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
const tomorrow = new Date(zonedNow); const tomorrow = new Date(zonedNow);
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow'; if (isSameDayDateFns(zonedDate, tomorrow)) return m.day_tomorrow();
const yesterday = new Date(zonedNow); const yesterday = new Date(zonedNow);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday'; if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
return formatInTimeZone(date, timeZone, 'EEE d MMM'); return formatZoned(date, timeZone, 'EEE d MMM');
} }
/** /**
+7 -3
View File
@@ -1,5 +1,7 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { deLocalizeHref, localizeHref } from '$lib/paraglide/runtime';
import type { GeoLocation } from '$lib/stores/settings'; import type { GeoLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => { export const geoLocationNameToRoute = (name: string) => {
@@ -117,10 +119,12 @@ export async function resolveLocationFromRoute({
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves // trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check // every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever. // never holds and the redirect loops forever. The comparison also has to
// ignore the locale prefix the URL carries, while the redirect keeps it -
// otherwise every localized URL would bounce back to English.
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`; const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
if (event.url.pathname !== canonicalPath) { if (deLocalizeHref(event.url.pathname) !== canonicalPath) {
throw redirect(303, canonicalPath); throw redirect(303, localizeHref(canonicalPath));
} }
return location; return location;
+3 -1
View File
@@ -11,6 +11,8 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import './layout.css'; import './layout.css';
let { children } = $props(); let { children } = $props();
@@ -40,7 +42,7 @@
}); });
// the maps page embeds a full-bleed map: no padding, no scrolling // the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps')); let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
let sidebarCollapsed = $state(false); let sidebarCollapsed = $state(false);
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
+5 -1
View File
@@ -1,7 +1,11 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
throw redirect(303, '/weather/week/'); // bare `/` lands in the locale the strategy resolved (URL, cookie, then the
// browser's own languages), not always English
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad; }) satisfies PageLoad;
+10 -7
View File
@@ -5,6 +5,9 @@
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -29,15 +32,15 @@
// load resolves (and any weather page that doesn't carry a location). // load resolves (and any weather page that doesn't carry a location).
let location = $derived($page.data.location ?? $storedLocation); let location = $derived($page.data.location ?? $storedLocation);
const SUBTITLES: [string, string][] = [ const SUBTITLES: [string, () => string][] = [
['/weather/week', '7-day forecast'], ['/weather/week', m.page_week_subtitle],
['/weather/compare', 'Model comparison'], ['/weather/compare', m.page_compare_subtitle],
['/weather/14-day', '14-day ensemble forecast'], ['/weather/14-day', m.page_14day_subtitle],
['/weather/seasonal', 'Seasonal outlook'], ['/weather/seasonal', m.page_seasonal_subtitle],
['/weather/historical', 'Historical weather'] ['/weather/historical', m.page_historical_subtitle]
]; ];
let subtitle = $derived( let subtitle = $derived(
SUBTITLES.find(([prefix]) => $page.url.pathname.startsWith(prefix))?.[1] ?? null SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
); );
</script> </script>
+4 -1
View File
@@ -1,7 +1,10 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
throw redirect(303, '/weather/week/'); // keep the visitor in their language when the bare /weather path is hit
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad; }) satisfies PageLoad;
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }), href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{ {
replaceState: true replaceState: true
} }
@@ -9,6 +9,7 @@
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts'; import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { import {
type DaylightBand, type DaylightBand,
type EnsembleForecastResult, type EnsembleForecastResult,
@@ -281,7 +282,7 @@
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups} groups={ensembleModelGroups}
label="Ensemble model" label={m.model_ensemble()}
onModelChange={(model) => { onModelChange={(model) => {
params.models = [model]; params.models = [model];
storedEnsembleModel.set(model); storedEnsembleModel.set(model);
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }), href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{ {
replaceState: true replaceState: true
} }
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
resolve('/weather/historical/[location]', { href('/weather/historical/[location]', {
location: buildLocationRoute(get(storedLocation)) location: buildLocationRoute(get(storedLocation))
}), }),
{ replaceState: true } { replaceState: true }
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
resolve('/weather/seasonal/[location]', { href('/weather/seasonal/[location]', {
location: buildLocationRoute(get(storedLocation)) location: buildLocationRoute(get(storedLocation))
}), }),
{ replaceState: true } { replaceState: true }
+4 -6
View File
@@ -3,21 +3,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(href('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }), {
resolve('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true replaceState: true
} });
);
}); });
</script> </script>
@@ -9,6 +9,8 @@
storedChartRange storedChartRange
} from '$lib/stores/settings'; } from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables'; import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
interface Props { interface Props {
@@ -22,11 +24,11 @@
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables))); let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [ const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' }, { value: 'auto', label: m.default_range_auto(), hint: m.default_range_auto_hint() },
{ value: 'today', label: 'Today', hint: 'Open on the current day' }, { value: 'today', label: m.range_today(), hint: '' },
{ value: '3d', label: '3 days', hint: 'Open on the next three days' }, { value: '3d', label: m.range_3_days(), hint: '' },
{ value: '5d', label: '5 days', hint: 'Open on the next five days' }, { value: '5d', label: m.range_5_days(), hint: '' },
{ value: 'all', label: 'All', hint: 'Open on the full forecast' } { value: 'all', label: m.range_all(), hint: '' }
]; ];
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key))); let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
@@ -177,7 +179,7 @@
<div class="rounded-xl border border-border p-3"> <div class="rounded-xl border border-border p-3">
<div class="mb-2 flex items-baseline justify-between gap-2"> <div class="mb-2 flex items-baseline justify-between gap-2">
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"> <span class="text-[11px] font-bold tracking-wider text-primary uppercase">
Default time range {m.default_range_title()}
</span> </span>
<span class="text-[11px] text-muted-foreground"> <span class="text-[11px] text-muted-foreground">
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint} {RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date'; import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import { import {
@@ -142,8 +143,8 @@
type="button" type="button"
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10" class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load the past 3 days" aria-label={m.strip_past_aria()}
title="Load the past 3 days" title={m.strip_past_aria()}
> >
<span class="inline-flex items-baseline gap-0.5"> <span class="inline-flex items-baseline gap-0.5">
<svg <svg
@@ -157,16 +158,16 @@
</svg> </svg>
<span class="text-sm leading-none font-extrabold">3</span> <span class="text-sm leading-none font-extrabold">3</span>
</span> </span>
<span class="text-[9px] leading-tight font-semibold">past</span> <span class="text-[9px] leading-tight font-semibold">{m.strip_past_label()}</span>
</button> </button>
{:else if locationRoute} {:else if locationRoute}
<!-- the past days are already in the strip: the only way further back <!-- the past days are already in the strip: the only way further back
is the archive, so the button hands over to it --> is the archive, so the button hands over to it -->
<a <a
href={resolve('/weather/historical/[location]', { location: locationRoute })} href={href('/weather/historical/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10" class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the historical weather archive" aria-label={m.strip_history_aria()}
title="Historical weather: any past date back to 1940" title={m.strip_history_title()}
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -182,7 +183,7 @@
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5" d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
/> />
</svg> </svg>
<span class="text-[9px] leading-tight font-semibold">history</span> <span class="text-[9px] leading-tight font-semibold">{m.strip_history_label()}</span>
</a> </a>
{/if} {/if}
@@ -355,8 +356,8 @@
type="button" type="button"
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10" class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtend} onclick={onExtend}
aria-label="Show the full 15-day forecast" aria-label={m.strip_extend_aria()}
title="Show the full 15-day forecast" title={m.strip_extend_aria()}
> >
<span class="inline-flex items-baseline gap-0.5"> <span class="inline-flex items-baseline gap-0.5">
<span class="text-sm leading-none font-extrabold">15</span> <span class="text-sm leading-none font-extrabold">15</span>
@@ -370,16 +371,16 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg> </svg>
</span> </span>
<span class="text-[9px] leading-tight font-semibold">days</span> <span class="text-[9px] leading-tight font-semibold">{m.strip_days_label()}</span>
</button> </button>
{:else if locationRoute} {:else if locationRoute}
<!-- the strip is at the model's limit: anything further out is a <!-- the strip is at the model's limit: anything further out is a
seasonal outlook, so the button hands over to it --> seasonal outlook, so the button hands over to it -->
<a <a
href={resolve('/weather/seasonal/[location]', { location: locationRoute })} href={href('/weather/seasonal/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10" class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the seasonal outlook" aria-label={m.strip_seasonal_aria()}
title="Seasonal outlook: monthly trends for the months ahead" title={m.strip_seasonal_title()}
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -391,7 +392,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
</svg> </svg>
<span class="text-[9px] leading-tight font-semibold">seasonal</span> <span class="text-[9px] leading-tight font-semibold">{m.strip_seasonal_label()}</span>
</a> </a>
{/if} {/if}
</div> </div>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date'; import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import { import {
buildDayNarrative, buildDayNarrative,
moonIllumination, moonIllumination,
@@ -28,7 +30,6 @@
daily.dailyDates.findIndex((d) => formatZoned(d, timezone, 'yyyy-MM-dd') === dayKey) daily.dailyDates.findIndex((d) => formatZoned(d, timezone, 'yyyy-MM-dd') === dayKey)
); );
let relLabel = $derived(getRelativeDayLabel(selectedDay, timezone)); let relLabel = $derived(getRelativeDayLabel(selectedDay, timezone));
let isToday = $derived(relLabel === 'Today');
let sentences = $derived( let sentences = $derived(
buildDayNarrative({ buildDayNarrative({
@@ -38,8 +39,7 @@
dailyDates: daily.dailyDates, dailyDates: daily.dailyDates,
timezone, timezone,
day: selectedDay, day: selectedDay,
units, units
isToday
}) })
); );
@@ -70,8 +70,8 @@
let daylight = $derived.by(() => { let daylight = $derived.by(() => {
if (!finite(daylightSeconds)) return null; if (!finite(daylightSeconds)) return null;
const h = Math.floor(daylightSeconds / 3600); const h = Math.floor(daylightSeconds / 3600);
const m = Math.round((daylightSeconds % 3600) / 60); const min = Math.round((daylightSeconds % 3600) / 60);
return `${h} h ${String(m).padStart(2, '0')} m`; return m.daylight_hours({ hours: h, minutes: String(min).padStart(2, '0') });
}); });
let sunshine = $derived.by(() => { let sunshine = $derived.by(() => {
@@ -98,13 +98,13 @@
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null); let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
</script> </script>
<section class="mt-6" aria-label="Written forecast"> <section class="mt-6" aria-label={m.summary_heading()}>
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm"> <div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5"> <div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold"> <h3 class="text-base font-bold">
{formatZoned(selectedDay, timezone, 'EEEE')} {formatZoned(selectedDay, timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground"> <span class="font-semibold text-muted-foreground">
in words{relLabel === formatZoned(selectedDay, timezone, 'EEEE') {m.summary_heading()}{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
? '' ? ''
: ` (${relLabel})`} : ` (${relLabel})`}
</span> </span>
@@ -118,7 +118,7 @@
</p> </p>
{:else} {:else}
<p class="text-[15px] leading-relaxed text-muted-foreground"> <p class="text-[15px] leading-relaxed text-muted-foreground">
No hourly detail available for this day. {m.summary_no_data()}
</p> </p>
{/if} {/if}
@@ -129,7 +129,7 @@
{#if sunrise} {#if sunrise}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Sunrise {m.label_sunrise()}
</dt> </dt>
<dd class="font-semibold tabular-nums">{sunrise}</dd> <dd class="font-semibold tabular-nums">{sunrise}</dd>
</div> </div>
@@ -137,7 +137,7 @@
{#if sunset} {#if sunset}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Sunset {m.label_sunset()}
</dt> </dt>
<dd class="font-semibold tabular-nums">{sunset}</dd> <dd class="font-semibold tabular-nums">{sunset}</dd>
</div> </div>
@@ -145,12 +145,14 @@
{#if daylight} {#if daylight}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Daylight {m.label_daylight()}
</dt> </dt>
<dd class="font-semibold tabular-nums"> <dd class="font-semibold tabular-nums">
{daylight} {daylight}
{#if sunshine != null} {#if sunshine != null}
<span class="font-medium text-muted-foreground">· {sunshine}% sun</span> <span class="font-medium text-muted-foreground"
>· {m.sunshine_share({ percent: sunshine })}</span
>
{/if} {/if}
</dd> </dd>
</div> </div>
@@ -158,7 +160,7 @@
{#if finite(uv)} {#if finite(uv)}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
UV index {m.label_uv_index()}
</dt> </dt>
<dd class="font-semibold tabular-nums"> <dd class="font-semibold tabular-nums">
{uv.toFixed(1)} {uv.toFixed(1)}
@@ -169,7 +171,7 @@
{#if moonrise} {#if moonrise}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moonrise {m.label_moonrise()}
</dt> </dt>
<dd class="font-semibold tabular-nums">{moonrise}</dd> <dd class="font-semibold tabular-nums">{moonrise}</dd>
</div> </div>
@@ -177,7 +179,7 @@
{#if moonset} {#if moonset}
<div> <div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moonset {m.label_moonset()}
</dt> </dt>
<dd class="font-semibold tabular-nums">{moonset}</dd> <dd class="font-semibold tabular-nums">{moonset}</dd>
</div> </div>
@@ -196,7 +198,7 @@
</svg> </svg>
<div class="min-w-0"> <div class="min-w-0">
<div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"> <div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moon {m.label_moon()}
</div> </div>
<div class="truncate font-semibold"> <div class="truncate font-semibold">
{moonPhaseName(phase!)} {moonPhaseName(phase!)}
@@ -12,6 +12,7 @@
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { groupHover, setGroupHover } from '$lib/charts'; import { groupHover, setGroupHover } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
@@ -402,7 +403,7 @@
> >
<h3 class="text-base font-bold"> <h3 class="text-base font-bold">
{formatZoned(selectedDay, data.timezone, 'EEEE')} {formatZoned(selectedDay, data.timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground"> hourly</span> <span class="font-semibold text-muted-foreground"> {m.hourly_heading()}</span>
<span <span
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground" class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
> >
@@ -13,6 +13,7 @@
import { ChartContainer, downloadChartsPng } from '$lib/components/charts'; import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts'; import { CanvasChart, groupRange } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import ChartCustomizer from './ChartCustomizer.svelte'; import ChartCustomizer from './ChartCustomizer.svelte';
@@ -137,11 +138,13 @@
let rangePresets = $derived.by(() => { let rangePresets = $derived.by(() => {
const isToday = isSameDayInZone(now, selectedDay, data.timezone); const isToday = isSameDayInZone(now, selectedDay, data.timezone);
return [ return [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) }, { label: m.range_today(), apply: () => setRangeDays(new Date(), 1) },
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]), ...(isToday
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) }, ? []
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) }, : [{ label: m.range_selected_day(), apply: () => setRangeDays(selectedDay, 1) }]),
{ label: 'All', apply: () => showFullRange() } { label: m.range_3_days(), apply: () => setRangeDays(new Date(), 3) },
{ label: m.range_5_days(), apply: () => setRangeDays(new Date(), 5) },
{ label: m.range_all(), apply: () => showFullRange() }
]; ];
}); });
@@ -215,7 +218,7 @@
<section class="mt-8" transition:fade={{ duration: 200 }}> <section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold"> <h3 class="text-lg font-bold">
Meteograms {m.meteograms_heading()}
<span class="font-semibold text-muted-foreground"> <span class="font-semibold text-muted-foreground">
{formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel( {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
selectedDay, selectedDay,
@@ -231,12 +234,12 @@
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]" <kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd >Ctrl</kbd
> >
+ scroll to zoom {m.meteograms_zoom_hint_end()}
</span> </span>
<div <div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold" class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group" role="group"
aria-label="Chart time range" aria-label={m.range_group_aria()}
> >
{#each rangePresets as preset (preset.label)} {#each rangePresets as preset (preset.label)}
<button <button
@@ -262,7 +265,7 @@
> >
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" /> <path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
</svg> </svg>
Customize {m.meteograms_customize()}
</button> </button>
<button <button
type="button" type="button"
@@ -316,7 +319,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" /> <path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg> </svg>
Reset zoom {m.reset_zoom()}
</button> </button>
{/if} {/if}
</div> </div>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as m from '$lib/paraglide/messages';
import { type WeatherModelGroup, modelGroups } from '../../options'; import { type WeatherModelGroup, modelGroups } from '../../options';
interface Props { interface Props {
@@ -15,7 +17,7 @@
selectedModel, selectedModel,
onModelChange, onModelChange,
groups = modelGroups, groups = modelGroups,
label = 'Weather model' label = m.model_weather()
}: Props = $props(); }: Props = $props();
let model = $derived( let model = $derived(
@@ -1,11 +1,17 @@
/** /**
* Turns a day's hourly forecast into a short written summary - the kind of * Turns a day's hourly forecast into a short written summary - the kind of
* sentence a person would actually say about the weather, rather than another * sentence a person would actually say about the weather, rather than another
* table of numbers. Everything here is derived from the same data the charts * table of numbers. Everything is derived from the same data the charts plot,
* plot, so the wording can never disagree with them. * so the wording can never disagree with them.
*
* Every sentence is a whole message, not a string built from glued-together
* fragments: word order, prepositions and agreement differ per language, so
* each locale owns its full sentence and only receives the values.
*/ */
import { formatZoned } from '$lib/utils/date'; import { formatZoned } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import { import {
type WeatherUnits, type WeatherUnits,
getPrecipUnit, getPrecipUnit,
@@ -30,6 +36,17 @@ const CATEGORY_RANK: Record<Category, number> = {
thunder: 7 thunder: 7
}; };
const CATEGORY_MESSAGE: Record<Category, () => string> = {
clear: m.cond_clear,
fair: m.cond_fair,
cloudy: m.cond_cloudy,
fog: m.cond_fog,
drizzle: m.cond_drizzle,
rain: m.cond_rain,
snow: m.cond_snow,
thunder: m.cond_thunder
};
/** WMO weather code → condition family. */ /** WMO weather code → condition family. */
function categoryOf(code: number): Category { function categoryOf(code: number): Category {
if (code >= 95) return 'thunder'; if (code >= 95) return 'thunder';
@@ -45,29 +62,18 @@ function categoryOf(code: number): Category {
return 'clear'; return 'clear';
} }
const CATEGORY_PHRASE: Record<Category, string> = {
clear: 'clear',
fair: 'partly cloudy',
cloudy: 'overcast',
fog: 'foggy',
drizzle: 'drizzly',
rain: 'wet',
snow: 'snowy',
thunder: 'stormy'
};
interface Period { interface Period {
label: string; message: () => string;
/** Inclusive start hour, exclusive end hour (local). */ /** Inclusive start hour, exclusive end hour (local). */
from: number; from: number;
to: number; to: number;
} }
const PERIODS: Period[] = [ const PERIODS: Period[] = [
{ label: 'overnight', from: 0, to: 6 }, { message: m.period_overnight, from: 0, to: 6 },
{ label: 'this morning', from: 6, to: 12 }, { message: m.period_morning, from: 6, to: 12 },
{ label: 'this afternoon', from: 12, to: 18 }, { message: m.period_afternoon, from: 12, to: 18 },
{ label: 'this evening', from: 18, to: 24 } { message: m.period_evening, from: 18, to: 24 }
]; ];
export interface NarrativeInput { export interface NarrativeInput {
@@ -79,8 +85,6 @@ export interface NarrativeInput {
/** The day being described. */ /** The day being described. */
day: Date; day: Date;
units: WeatherUnits; units: WeatherUnits;
/** True when `day` is today, which changes the wording to the present tense. */
isToday: boolean;
} }
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v); const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
@@ -137,7 +141,7 @@ function capitalise(s: string): string {
* paragraph). Returns an empty list when the day has no usable data. * paragraph). Returns an empty list when the day has no usable data.
*/ */
export function buildDayNarrative(input: NarrativeInput): string[] { export function buildDayNarrative(input: NarrativeInput): string[] {
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units, isToday } = input; const { hourly, hourlyDates, daily, dailyDates, timezone, day, units } = input;
const idx = hoursOfDay(hourlyDates, day, timezone); const idx = hoursOfDay(hourlyDates, day, timezone);
if (idx.length === 0) return []; if (idx.length === 0) return [];
@@ -151,11 +155,13 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const precipUnit = getPrecipUnit(units); const precipUnit = getPrecipUnit(units);
const at = (arr: number[] | undefined, i: number) => (arr ? arr[i] : undefined); const at = (arr: number[] | undefined, i: number) => (arr ? arr[i] : undefined);
const hourOf = (i: number) => Number(formatZoned(hourlyDates[i], timezone, 'H')); const hourOf = (i: number) => Number(formatZoned(hourlyDates[i], timezone, 'H'));
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
const sentences: string[] = []; const sentences: string[] = [];
// ─── How the sky behaves through the day ──────────────────────────────────── // ─── How the sky behaves through the day ────────────────────────────────────
const segments: { label: string; category: Category }[] = []; const segments: { period: Period; category: Category }[] = [];
for (const period of PERIODS) { for (const period of PERIODS) {
const inPeriod = idx.filter((i) => { const inPeriod = idx.filter((i) => {
const h = hourOf(i); const h = hourOf(i);
@@ -163,37 +169,47 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
}); });
if (inPeriod.length < 2) continue; if (inPeriod.length < 2) continue;
const cat = dominantCategory(inPeriod.map((i) => hourly.weather_code?.[i]).filter(finite)); const cat = dominantCategory(inPeriod.map((i) => hourly.weather_code?.[i]).filter(finite));
if (cat) segments.push({ label: period.label, category: cat }); if (cat) segments.push({ period, category: cat });
} }
if (segments.length > 0) { if (segments.length > 0) {
// collapse neighbouring periods that share a description // collapse neighbouring periods that share a description
const runs: { labels: string[]; category: Category }[] = []; const runs: { period: Period; category: Category }[] = [];
for (const seg of segments) { for (const seg of segments) {
const last = runs[runs.length - 1]; const last = runs[runs.length - 1];
if (last && last.category === seg.category) last.labels.push(seg.label); if (!last || last.category !== seg.category) runs.push(seg);
else runs.push({ labels: [seg.label], category: seg.category });
} }
const phrase = (r: { category: Category }) => CATEGORY_MESSAGE[r.category]();
if (runs.length === 1) { if (runs.length === 1) {
sentences.push(capitalise(m.forecast_sky_all_day({ condition: phrase(runs[0]) })));
} else if (runs.length === 2) {
sentences.push( sentences.push(
`${capitalise(CATEGORY_PHRASE[runs[0].category])} ${isToday ? 'all day' : 'throughout the day'}.` capitalise(
m.forecast_sky_two({
c1: phrase(runs[0]),
p1: runs[0].period.message(),
c2: phrase(runs[1]),
p2: runs[1].period.message()
})
)
); );
} else { } else {
// Four clauses is a mouthful; keep the opening, the first change and // Four clauses is a mouthful: keep the opening, the first change and
// where the day ends up. // where the day ends up.
const kept = runs.length > 3 ? [runs[0], runs[1], runs[runs.length - 1]] : runs; const kept = [runs[0], runs[1], runs[runs.length - 1]];
const parts = kept.map((run, i) => { sentences.push(
const phrase = CATEGORY_PHRASE[run.category]; capitalise(
const when = run.labels[0]; m.forecast_sky_three({
if (i === 0) return `${capitalise(phrase)} ${when}`; c1: phrase(kept[0]),
// only the first change gets a verb; later ones read as a list p1: kept[0].period.message(),
if (i > 1) return `then ${phrase} ${when}`; c2: phrase(kept[1]),
const prev = kept[i - 1].category; p2: kept[1].period.message(),
if (CATEGORY_RANK[run.category] > CATEGORY_RANK[prev]) return `turning ${phrase} ${when}`; c3: phrase(kept[2]),
return `${run.category === 'clear' || run.category === 'fair' ? 'clearing to' : 'easing to'} ${phrase} ${when}`; p3: kept[2].period.message()
}); })
sentences.push(`${parts.join(', ')}.`); )
);
} }
} }
@@ -203,15 +219,12 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const high = Math.max(...temps); const high = Math.max(...temps);
const low = Math.min(...temps); const low = Math.min(...temps);
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite); const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
let sentence = `Highs near ${high.toFixed(0)}${tempUnit}, down to ${low.toFixed(0)}${tempUnit}`; const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
if (feels.length > 0) { sentences.push(
const feelsHigh = Math.max(...feels); feelsHigh != null && Math.abs(feelsHigh - high) >= 3
const delta = feelsHigh - high; ? m.forecast_temp_feels({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
if (Math.abs(delta) >= 3) { : m.forecast_temp({ high: temp(high), low: temp(low) })
sentence += `, though it will feel more like ${feelsHigh.toFixed(0)}${tempUnit}`; );
}
}
sentences.push(`${sentence}.`);
} }
// ─── Precipitation ────────────────────────────────────────────────────────── // ─── Precipitation ──────────────────────────────────────────────────────────
@@ -225,7 +238,7 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
if (total >= wetThreshold) { if (total >= wetThreshold) {
// name the window carrying most of the total // name the window carrying most of the total
let bestLabel = ''; let bestPeriod: Period | null = null;
let bestAmount = 0; let bestAmount = 0;
for (const period of PERIODS) { for (const period of PERIODS) {
const amount = idx const amount = idx
@@ -235,22 +248,19 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
if (amount > bestAmount) { if (amount > bestAmount) {
bestAmount = amount; bestAmount = amount;
bestLabel = period.label; bestPeriod = period;
} }
} }
const amountText = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`; const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
const share = bestAmount / total;
sentences.push( sentences.push(
bestLabel && share >= 0.5 bestPeriod && bestAmount / total >= 0.5
? `Around ${amountText} of precipitation, most of it ${bestLabel}.` ? m.forecast_precip_window({ amount, when: bestPeriod.message() })
: `Around ${amountText} of precipitation spread through the day.` : m.forecast_precip_spread({ amount })
); );
} else if (peakProb >= 30) { } else if (peakProb >= 30) {
sentences.push( sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
`Mostly dry, with up to a ${Math.round(peakProb)}% chance of catching a shower.`
);
} else { } else {
sentences.push('Staying dry.'); sentences.push(m.forecast_precip_dry());
} }
// ─── Wind ─────────────────────────────────────────────────────────────────── // ─── Wind ───────────────────────────────────────────────────────────────────
@@ -260,18 +270,32 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const dir = dayIndex >= 0 ? at(daily.winddirection_10m_dominant, dayIndex) : undefined; const dir = dayIndex >= 0 ? at(daily.winddirection_10m_dominant, dayIndex) : undefined;
const gusts = idx.map((i) => hourly.wind_gusts_10m?.[i]).filter(finite); const gusts = idx.map((i) => hourly.wind_gusts_10m?.[i]).filter(finite);
const maxGust = gusts.length > 0 ? Math.max(...gusts) : 0; const maxGust = gusts.length > 0 ? Math.max(...gusts) : 0;
const from = finite(dir) ? ` from the ${getWindDirectionLabel(dir)}` : ''; const gusty = maxGust > maxWind * 1.4;
let sentence = `Wind${from} up to ${maxWind.toFixed(0)} ${windUnit}`; // below ~5 km/h (or the equivalent in other units) there is nothing to say
if (maxGust > maxWind * 1.4) sentence += `, gusting ${maxGust.toFixed(0)}`; const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
sentences.push(`${sentence}.`);
if (calm && !gusty) {
sentences.push(m.forecast_calm());
} else if (finite(dir)) {
const direction = getWindDirectionLabel(dir);
sentences.push(
gusty
? m.forecast_wind_dir_gusts({ direction, speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind_dir({ direction, speed: speed(maxWind) })
);
} else {
sentences.push(
gusty
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind({ speed: speed(maxWind) })
);
}
} }
// ─── UV ───────────────────────────────────────────────────────────────────── // ─── UV ─────────────────────────────────────────────────────────────────────
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined; const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
if (finite(uv) && uv >= 6) { if (finite(uv) && uv >= 6) {
sentences.push( sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
`UV peaks at ${uv.toFixed(0)} - ${uvLabel(uv).toLowerCase()}, so cover up around midday.`
);
} }
return sentences; return sentences;
@@ -279,11 +303,11 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
/** WHO exposure category for a UV index value. */ /** WHO exposure category for a UV index value. */
export function uvLabel(uv: number): string { export function uvLabel(uv: number): string {
if (uv < 3) return 'Low'; if (uv < 3) return m.uv_low();
if (uv < 6) return 'Moderate'; if (uv < 6) return m.uv_moderate();
if (uv < 8) return 'High'; if (uv < 8) return m.uv_high();
if (uv < 11) return 'Very high'; if (uv < 11) return m.uv_very_high();
return 'Extreme'; return m.uv_extreme();
} }
/** Tailwind text colour matching the WHO UV bands. */ /** Tailwind text colour matching the WHO UV bands. */
@@ -298,14 +322,14 @@ export function uvColorClass(uv: number): string {
/** Name of the lunar phase for a 0-1 fraction (0 and 1 are new moon). */ /** Name of the lunar phase for a 0-1 fraction (0 and 1 are new moon). */
export function moonPhaseName(phase: number): string { export function moonPhaseName(phase: number): string {
const p = ((phase % 1) + 1) % 1; const p = ((phase % 1) + 1) % 1;
if (p < 0.03 || p >= 0.97) return 'New moon'; if (p < 0.03 || p >= 0.97) return m.moon_new();
if (p < 0.22) return 'Waxing crescent'; if (p < 0.22) return m.moon_waxing_crescent();
if (p < 0.28) return 'First quarter'; if (p < 0.28) return m.moon_first_quarter();
if (p < 0.47) return 'Waxing gibbous'; if (p < 0.47) return m.moon_waxing_gibbous();
if (p < 0.53) return 'Full moon'; if (p < 0.53) return m.moon_full();
if (p < 0.72) return 'Waning gibbous'; if (p < 0.72) return m.moon_waning_gibbous();
if (p < 0.78) return 'Last quarter'; if (p < 0.78) return m.moon_last_quarter();
return 'Waning crescent'; return m.moon_waning_crescent();
} }
/** Illuminated fraction of the disc, 0 at new moon and 1 at full. */ /** Illuminated fraction of the disc, 0 at new moon and 1 at full. */
+23 -3
View File
@@ -25,19 +25,39 @@ const config = {
console.warn(`prerender skipped ${path}: ${message}`); console.warn(`prerender skipped ${path}: ${message}`);
}, },
entries: (() => { entries: (() => {
// Locale lives in the path (see the paraglide config in vite.config.ts),
// so a localized URL is not a route SvelteKit can discover on its own -
// each one has to be listed. City pages are prerendered for the base
// locale only; the other languages reach them through the SPA fallback,
// which keeps the build from ballooning to cities × locales.
const locales = ['en', 'de', 'es', 'fr', 'it'];
const shared = [
'/weather/week',
'/weather/compare',
'/weather/14-day',
'/weather/seasonal',
'/weather/historical',
'/weather/maps',
'/about',
'/legal/imprint',
'/legal/privacy',
'/legal/terms'
];
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
try { try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json'); const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
const raw = fs.readFileSync(citiesPath, 'utf-8'); const raw = fs.readFileSync(citiesPath, 'utf-8');
const cities = JSON.parse(raw); const cities = JSON.parse(raw);
if (Array.isArray(cities)) { if (Array.isArray(cities)) {
const cityEntries = cities.map((c) => `/weather/week/${c}`); const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
// Keep the default wildcard to include other routes // Keep the default wildcard to include other routes
return ['*', ...cityEntries]; return ['*', ...localized, ...cityEntries];
} }
} catch { } catch {
// If anything goes wrong, fall back to default behavior // If anything goes wrong, fall back to default behavior
} }
return ['*']; return ['*', ...localized];
})() })()
} }
} }
+32
View File
@@ -0,0 +1,32 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 950 } });
await page.goto('http://localhost:5181/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(12000);
const info = await page.evaluate(() => {
const btn = document.querySelector('[data-slot="select-trigger"], button.group.min-h-12') ||
[...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
const card = document.querySelector('.strip-cell');
const cs = (el) => {
const s = getComputedStyle(el);
return { prop: s.transitionProperty, dur: s.transitionDuration, delay: s.transitionDelay };
};
return { button: btn ? cs(btn) : null, cardEl: card ? cs(card) : null, btnClass: btn?.className.slice(0, 120) };
});
console.log('BEFORE switch:', JSON.stringify(info, null, 1));
// flip the theme and watch when each element's background actually moves
await page.getByRole('button', { name: /^Theme: / }).click();
const samples = [];
for (let i = 0; i < 12; i++) {
samples.push(await page.evaluate(() => {
const btn = [...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
const card = document.querySelector('.strip-cell');
const g = (el) => (el ? getComputedStyle(el).backgroundColor : null);
return { t: Math.round(performance.now() % 100000), btn: g(btn), card: g(card), dur: btn ? getComputedStyle(btn).transitionDuration : null };
}));
await page.waitForTimeout(60);
}
console.log(JSON.stringify(samples.map((s) => `${s.btn} | ${s.card} | ${s.dur}`), null, 1));
await browser.close();
+26 -1
View File
@@ -1,3 +1,4 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
@@ -29,8 +30,32 @@ const viteServerConfig = (): Plugin => ({
} }
}); });
// Every locale carries its own path prefix, English included, so a URL always
// says which language it is in. `trailingSlash: 'always'` (see routes/+layout.ts)
// means the patterns have to match both `/de/weather/week/` and `/de`.
const LOCALES = ['en', 'de', 'es', 'fr', 'it'] as const;
const urlPatterns = [
{
pattern: '/:path(.*)?',
localized: LOCALES.map((locale) => [locale, `/${locale}/:path(.*)?`] as [string, string])
}
];
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), viteServerConfig()], plugins: [
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
// the URL is authoritative; the cookie only carries a choice forward to
// the next bare visit, and the browser's languages seed a first visit
strategy: ['url', 'cookie', 'preferredLanguage', 'baseLocale'],
urlPatterns
}),
tailwindcss(),
sveltekit(),
viteServerConfig()
],
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },