From bffb854f120b9f2266a96b905bf24d0ef3a338d8 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Sun, 21 Jun 2026 13:53:30 -0500 Subject: [PATCH] feat: add nighttime weather icons based on sunrise/sunset Display moon/night icons when the current time is after sunset or before sunrise. For current weather, use the is_day field from the Open-Meteo API. For hourly forecasts, request daily sunrise/sunset data and compare each hour against those times. - Add IsDay property to CurrentWeather model - Add sunrise/sunset to DailyForecast and HourlyDailyInfo models - Add nighttime icon variants (ClearSkyNight, MainlyClearNight, etc.) - Update GetIconForWeatherCode with optional isNight parameter - Update all callers to pass night state Closes #138 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockBands/PinnedWeatherBand.cs | 2 +- WeatherExtension/Icons.cs | 19 ++++++--- WeatherExtension/Models/ForecastData.cs | 18 +++++++++ WeatherExtension/Models/WeatherData.cs | 3 ++ WeatherExtension/Pages/HourlyForecastPage.cs | 40 ++++++++++++++++++- WeatherExtension/Pages/WeatherDetailPage.cs | 2 +- WeatherExtension/Pages/WeatherListPage.cs | 2 +- WeatherExtension/Services/OpenMeteoService.cs | 6 +-- .../Services/WeatherJsonContext.cs | 1 + 9 files changed, 81 insertions(+), 12 deletions(-) diff --git a/WeatherExtension/DockBands/PinnedWeatherBand.cs b/WeatherExtension/DockBands/PinnedWeatherBand.cs index d97df9c..e6b4a91 100644 --- a/WeatherExtension/DockBands/PinnedWeatherBand.cs +++ b/WeatherExtension/DockBands/PinnedWeatherBand.cs @@ -107,7 +107,7 @@ private async Task UpdateWeatherAsync() "{0} {1}", WeatherFormatter.Temperature(current.Temperature, tempUnit), condition); - Icon = Icons.GetIconForWeatherCode(current.WeatherCode); + Icon = Icons.GetIconForWeatherCode(current.WeatherCode, isNight: current.IsDay == 0); if (DockItem is CommandItem dockCommandItem) { diff --git a/WeatherExtension/Icons.cs b/WeatherExtension/Icons.cs index 087f31f..2e2eae9 100644 --- a/WeatherExtension/Icons.cs +++ b/WeatherExtension/Icons.cs @@ -50,13 +50,22 @@ internal sealed class Icons internal static IconInfo ThunderstormHail { get; } = new IconInfo("⛈️"); - internal static IconInfo GetIconForWeatherCode(int weatherCode) + // Nighttime variants + internal static IconInfo ClearSkyNight { get; } = new IconInfo("🌙"); + + internal static IconInfo MainlyClearNight { get; } = new IconInfo("🌙"); + + internal static IconInfo PartlyCloudyNight { get; } = new IconInfo("☁️"); + + internal static IconInfo WeatherIconNight { get; } = new IconInfo("🌙"); + + internal static IconInfo GetIconForWeatherCode(int weatherCode, bool isNight = false) { return weatherCode switch { - 0 => ClearSky, - 1 or 2 => MainlyClear, - 3 => PartlyCloudy, + 0 => isNight ? ClearSkyNight : ClearSky, + 1 or 2 => isNight ? MainlyClearNight : MainlyClear, + 3 => isNight ? PartlyCloudyNight : PartlyCloudy, 45 or 48 => Fog, 51 or 53 or 55 => Drizzle, 56 or 57 => DrizzleFreezing, @@ -67,7 +76,7 @@ internal static IconInfo GetIconForWeatherCode(int weatherCode) 85 or 86 => SnowShowers, 95 => Thunderstorm, 96 or 99 => ThunderstormHail, - _ => WeatherIcon, + _ => isNight ? WeatherIconNight : WeatherIcon, }; } diff --git a/WeatherExtension/Models/ForecastData.cs b/WeatherExtension/Models/ForecastData.cs index 3bece2c..b891fbc 100644 --- a/WeatherExtension/Models/ForecastData.cs +++ b/WeatherExtension/Models/ForecastData.cs @@ -39,6 +39,12 @@ public sealed class DailyForecast [JsonPropertyName("precipitation_probability_max")] public List? PrecipitationProbabilityMax { get; set; } + + [JsonPropertyName("sunrise")] + public List? Sunrise { get; set; } + + [JsonPropertyName("sunset")] + public List? Sunset { get; set; } } public sealed class HourlyForecastData @@ -46,6 +52,9 @@ public sealed class HourlyForecastData [JsonPropertyName("hourly")] public HourlyForecast? Hourly { get; set; } + [JsonPropertyName("daily")] + public HourlyDailyInfo? Daily { get; set; } + [JsonPropertyName("latitude")] public double Latitude { get; set; } @@ -80,4 +89,13 @@ public sealed class HourlyForecast public List? RelativeHumidity { get; set; } } +public sealed class HourlyDailyInfo +{ + [JsonPropertyName("sunrise")] + public List? Sunrise { get; set; } + + [JsonPropertyName("sunset")] + public List? Sunset { get; set; } +} + #pragma warning restore SA1402 // File may only contain a single type diff --git a/WeatherExtension/Models/WeatherData.cs b/WeatherExtension/Models/WeatherData.cs index 3a8b70b..71219c5 100644 --- a/WeatherExtension/Models/WeatherData.cs +++ b/WeatherExtension/Models/WeatherData.cs @@ -45,6 +45,9 @@ public sealed class CurrentWeather [JsonPropertyName("wind_direction_10m")] public int WindDirection { get; set; } + + [JsonPropertyName("is_day")] + public int IsDay { get; set; } = 1; } #pragma warning restore SA1402 // File may only contain a single type diff --git a/WeatherExtension/Pages/HourlyForecastPage.cs b/WeatherExtension/Pages/HourlyForecastPage.cs index b1e99df..892b6e9 100644 --- a/WeatherExtension/Pages/HourlyForecastPage.cs +++ b/WeatherExtension/Pages/HourlyForecastPage.cs @@ -93,6 +93,10 @@ private List CreateHourlyItems(HourlyForecastData hourlyData) var windUnit = _settingsManager.WindSpeedUnit == "mph" ? "mph" : "km/h"; var now = DateTime.Now; + // Parse sunrise/sunset times for night determination + var sunriseTimes = ParseDailyTimes(hourlyData.Daily?.Sunrise); + var sunsetTimes = ParseDailyTimes(hourlyData.Daily?.Sunset); + var count = hourly.Time?.Count ?? 0; for (var i = 0; i < count; i++) { @@ -122,6 +126,7 @@ private List CreateHourlyItems(HourlyForecastData hourlyData) var temperature = hourly.Temperature[i]; var feelsLike = hourly.ApparentTemperature[i]; var condition = Icons.GetWeatherDescription(weatherCode); + var isNight = IsNightTime(time, sunriseTimes, sunsetTimes); var precipProb = hourly.PrecipitationProbability != null && i < hourly.PrecipitationProbability.Count ? hourly.PrecipitationProbability[i] @@ -139,7 +144,7 @@ private List CreateHourlyItems(HourlyForecastData hourlyData) { Title = WeatherFormatter.Hour(time, _settingsManager.Use24HourClock), Subtitle = $"{condition} — {temperature:F0}{tempUnit}", - Icon = Icons.GetIconForWeatherCode(weatherCode), + Icon = Icons.GetIconForWeatherCode(weatherCode, isNight), Details = new Details { Title = WeatherFormatter.Hour(time, _settingsManager.Use24HourClock), @@ -159,6 +164,39 @@ private List CreateHourlyItems(HourlyForecastData hourlyData) return items; } + private static List ParseDailyTimes(List? times) + { + var result = new List(); + if (times == null) + { + return result; + } + + foreach (var t in times) + { + if (DateTime.TryParse(t, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + result.Add(parsed); + } + } + + return result; + } + + private static bool IsNightTime(DateTime time, List sunriseTimes, List sunsetTimes) + { + // Find the sunrise/sunset for the same day + var sunrise = sunriseTimes.FirstOrDefault(s => s.Date == time.Date); + var sunset = sunsetTimes.FirstOrDefault(s => s.Date == time.Date); + + if (sunrise == default || sunset == default) + { + return false; + } + + return time < sunrise || time >= sunset; + } + public override IListItem[] GetItems() { lock (_sync) diff --git a/WeatherExtension/Pages/WeatherDetailPage.cs b/WeatherExtension/Pages/WeatherDetailPage.cs index 6bce88d..24e3f18 100644 --- a/WeatherExtension/Pages/WeatherDetailPage.cs +++ b/WeatherExtension/Pages/WeatherDetailPage.cs @@ -112,7 +112,7 @@ private ListItem CreateCurrentWeatherItem(WeatherData weatherData) { Title = Resources.current_weather, Subtitle = $"{condition} — {current.Temperature:F0}{tempUnit}", - Icon = Icons.GetIconForWeatherCode(current.WeatherCode), + Icon = Icons.GetIconForWeatherCode(current.WeatherCode, isNight: current.IsDay == 0), Details = new Details { Title = Resources.current_weather, diff --git a/WeatherExtension/Pages/WeatherListPage.cs b/WeatherExtension/Pages/WeatherListPage.cs index d5789d9..4652ef6 100644 --- a/WeatherExtension/Pages/WeatherListPage.cs +++ b/WeatherExtension/Pages/WeatherListPage.cs @@ -284,7 +284,7 @@ private ListItem CreateWeatherItem(GeocodingResult location, WeatherData weather { Title = location.DisplayName, Subtitle = WeatherFormatter.CurrentSubtitle(current, _settingsManager.TemperatureUnit), - Icon = Icons.GetIconForWeatherCode(current.WeatherCode), + Icon = Icons.GetIconForWeatherCode(current.WeatherCode, isNight: current.IsDay == 0), Tags = tags.ToArray(), Details = new Details { diff --git a/WeatherExtension/Services/OpenMeteoService.cs b/WeatherExtension/Services/OpenMeteoService.cs index afb70cc..1d3b2fd 100644 --- a/WeatherExtension/Services/OpenMeteoService.cs +++ b/WeatherExtension/Services/OpenMeteoService.cs @@ -73,7 +73,7 @@ internal OpenMeteoService(HttpMessageHandler handler) var url = string.Create( CultureInfo.InvariantCulture, - $"{BaseUrl}?latitude={latitude}&longitude={longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit={temperatureUnit}&wind_speed_unit={windSpeedUnit}&timezone=auto"); + $"{BaseUrl}?latitude={latitude}&longitude={longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,is_day&temperature_unit={temperatureUnit}&wind_speed_unit={windSpeedUnit}&timezone=auto"); var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); @@ -143,7 +143,7 @@ internal OpenMeteoService(HttpMessageHandler handler) var url = string.Create( CultureInfo.InvariantCulture, - $"{BaseUrl}?latitude={latitude}&longitude={longitude}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&temperature_unit={temperatureUnit}&timezone=auto"); + $"{BaseUrl}?latitude={latitude}&longitude={longitude}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset&temperature_unit={temperatureUnit}&timezone=auto"); var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); @@ -214,7 +214,7 @@ internal OpenMeteoService(HttpMessageHandler handler) var url = string.Create( CultureInfo.InvariantCulture, - $"{BaseUrl}?latitude={latitude}&longitude={longitude}&hourly=temperature_2m,apparent_temperature,weather_code,precipitation_probability,wind_speed_10m,relative_humidity_2m&temperature_unit={temperatureUnit}&wind_speed_unit={windSpeedUnit}&forecast_days=2&timezone=auto"); + $"{BaseUrl}?latitude={latitude}&longitude={longitude}&hourly=temperature_2m,apparent_temperature,weather_code,precipitation_probability,wind_speed_10m,relative_humidity_2m&daily=sunrise,sunset&temperature_unit={temperatureUnit}&wind_speed_unit={windSpeedUnit}&forecast_days=2&timezone=auto"); var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); diff --git a/WeatherExtension/Services/WeatherJsonContext.cs b/WeatherExtension/Services/WeatherJsonContext.cs index 315721d..27004b4 100644 --- a/WeatherExtension/Services/WeatherJsonContext.cs +++ b/WeatherExtension/Services/WeatherJsonContext.cs @@ -10,6 +10,7 @@ namespace Microsoft.CmdPal.Ext.Weather.Services; [JsonSerializable(typeof(WeatherData))] [JsonSerializable(typeof(ForecastData))] [JsonSerializable(typeof(HourlyForecastData))] +[JsonSerializable(typeof(HourlyDailyInfo))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(NominatimAddress))] [JsonSerializable(typeof(List))]