diff --git a/IMPERIAL_UNITS.md b/IMPERIAL_UNITS.md index 8895269..fa56fc5 100644 --- a/IMPERIAL_UNITS.md +++ b/IMPERIAL_UNITS.md @@ -79,8 +79,8 @@ curl "http://localhost:8002/London?lang=us&format=2&m" # Output: 51.5074,-0.1278: ☁️ 12°C 🌬️SW30km/h # Test from US IP (automatic detection) -curl -H "X-Forwarded-For: 8.8.8.8" "http://localhost:8002/London?format=2" -# Output: Uses imperial if 8.8.8.8 is detected as US IP +curl -H "X-Forwarded-For: 1.1.1.1" "http://localhost:8002/London?format=2" +# Output: Uses imperial as 1.1.1.1 is detected as US IP ``` ## Documentation Updates diff --git a/TARGET_ARCHITECTURE.md b/TARGET_ARCHITECTURE.md index 5ff3abe..03f3c34 100644 --- a/TARGET_ARCHITECTURE.md +++ b/TARGET_ARCHITECTURE.md @@ -23,10 +23,10 @@ Router ↓ Request Handler ↓ -Cache Check - ↓ (miss) Location Resolver ↓ +Provider interface cache check + ↓ (miss) Weather Provider (interface) ├─ MetNo (default) └─ Mock (tests) diff --git a/src/render/ansi.zig b/src/render/ansi.zig index f17c1cc..051c1a4 100644 --- a/src/render/ansi.zig +++ b/src/render/ansi.zig @@ -153,6 +153,8 @@ test "render with imperial units" { .location = "Chicago", .current = .{ .temp_c = 10.0, + .feels_like_c = 10.0, + .feels_like_f = 10.0 * 1.8 + 32, .temp_f = 50.0, .condition = "Clear", .weather_code = .clear, @@ -179,6 +181,8 @@ test "clear weather art" { .location = "Test", .current = .{ .temp_c = 20.0, + .feels_like_c = 20.0, + .feels_like_f = 20.0 * 1.8 + 32, .temp_f = 68.0, .condition = "Clear", .weather_code = .clear, @@ -204,6 +208,8 @@ test "partly cloudy weather art" { .location = "Test", .current = .{ .temp_c = 18.0, + .feels_like_c = 18.0, + .feels_like_f = 18.0 * 1.8 + 32, .temp_f = 64.0, .condition = "Partly cloudy", .weather_code = .clouds_few, @@ -229,6 +235,8 @@ test "cloudy weather art" { .location = "Test", .current = .{ .temp_c = 15.0, + .feels_like_c = 15.0, + .feels_like_f = 15.0 * 1.8 + 32, .temp_f = 59.0, .condition = "Cloudy", .weather_code = .clouds_overcast, @@ -254,6 +262,8 @@ test "rain weather art" { .location = "Test", .current = .{ .temp_c = 12.0, + .feels_like_c = 12.0, + .feels_like_f = 12.0 * 1.8 + 32, .temp_f = 54.0, .condition = "Rain", .weather_code = .rain_moderate, @@ -278,6 +288,8 @@ test "thunderstorm weather art" { .location = "Test", .current = .{ .temp_c = 14.0, + .feels_like_c = 14.0, + .feels_like_f = 14.0 * 1.8 + 32, .temp_f = 57.0, .condition = "Thunderstorm", .weather_code = .thunderstorm, @@ -302,6 +314,8 @@ test "snow weather art" { .location = "Test", .current = .{ .temp_c = -2.0, + .feels_like_c = -2.0, + .feels_like_f = -2.0 * 1.8 + 32, .temp_f = 28.0, .condition = "Snow", .weather_code = .snow, @@ -326,6 +340,8 @@ test "sleet weather art" { .location = "Test", .current = .{ .temp_c = 0.0, + .feels_like_c = 0.0, + .feels_like_f = 0.0 * 1.8 + 32, .temp_f = 32.0, .condition = "Sleet", .weather_code = .sleet, @@ -350,6 +366,8 @@ test "fog weather art" { .location = "Test", .current = .{ .temp_c = 8.0, + .feels_like_c = 8.0, + .feels_like_f = 8.0 * 1.8 + 32, .temp_f = 46.0, .condition = "Fog", .weather_code = .fog, @@ -374,6 +392,8 @@ test "unknown weather code art" { .location = "Test", .current = .{ .temp_c = 16.0, + .feels_like_c = 16.0, + .feels_like_f = 16.0 * 1.8 + 32, .temp_f = 61.0, .condition = "Unknown", .weather_code = .unknown, @@ -401,6 +421,8 @@ test "temperature matches between ansi and custom format" { .location = "PDX", .current = .{ .temp_c = 13.1, + .feels_like_c = 13.1, + .feels_like_f = 13.1 * 1.8 + 32, .temp_f = 55.6, .condition = "Clear", .weather_code = .clear, diff --git a/src/render/custom.zig b/src/render/custom.zig index ba44822..fde5756 100644 --- a/src/render/custom.zig +++ b/src/render/custom.zig @@ -82,6 +82,8 @@ test "render custom format with location and temp" { .location = "London", .current = .{ .temp_c = 7.0, + .feels_like_c = 7.0, + .feels_like_f = 7.0 * 1.8 + 32, .temp_f = 44.6, .condition = "Overcast", .weather_code = .clouds_overcast, @@ -109,6 +111,8 @@ test "render custom format with newline" { .location = "Paris", .current = .{ .temp_c = 10.0, + .feels_like_c = 10.0, + .feels_like_f = 10.0 * 1.8 + 32, .temp_f = 50.0, .condition = "Clear", .weather_code = .clear, @@ -135,6 +139,8 @@ test "render custom format with humidity and pressure" { .location = "Berlin", .current = .{ .temp_c = 5.0, + .feels_like_c = 5.0, + .feels_like_f = 5.0 * 1.8 + 32, .temp_f = 41.0, .condition = "Cloudy", .weather_code = .clouds_overcast, @@ -162,6 +168,8 @@ test "render custom format with imperial units" { .location = "NYC", .current = .{ .temp_c = 10.0, + .feels_like_c = 10.0, + .feels_like_f = 10.0 * 1.8 + 32, .temp_f = 50.0, .condition = "Clear", .weather_code = .clear, diff --git a/src/render/json.zig b/src/render/json.zig index 7ed92fc..7e10d1f 100644 --- a/src/render/json.zig +++ b/src/render/json.zig @@ -27,6 +27,8 @@ test "render json format" { .location = "London", .current = .{ .temp_c = 15.0, + .feels_like_c = 15.0, + .feels_like_f = 15.0 * 1.8 + 32, .temp_f = 59.0, .condition = "Partly cloudy", .weather_code = .clouds_few, diff --git a/src/render/line.zig b/src/render/line.zig index 4eda4f6..97caf3e 100644 --- a/src/render/line.zig +++ b/src/render/line.zig @@ -122,6 +122,8 @@ test "format 1" { .location = "London", .current = .{ .temp_c = 15.0, + .feels_like_c = 15.0, + .feels_like_f = 15.0 * 1.8 + 32, .temp_f = 59.0, .condition = "Clear", .weather_code = .clear, @@ -146,6 +148,8 @@ test "custom format" { .location = "London", .current = .{ .temp_c = 15.0, + .feels_like_c = 15.0, + .feels_like_f = 15.0 * 1.8 + 32, .temp_f = 59.0, .condition = "Clear", .weather_code = .clear, @@ -170,6 +174,8 @@ test "format 2 with imperial units" { .location = "Portland", .current = .{ .temp_c = 10.0, + .feels_like_c = 10.0, + .feels_like_f = 10.0 * 1.8 + 32, .temp_f = 50.0, .condition = "Cloudy", .weather_code = .clouds_overcast, diff --git a/src/render/v2.zig b/src/render/v2.zig index 20c0abb..f0fd948 100644 --- a/src/render/v2.zig +++ b/src/render/v2.zig @@ -74,6 +74,8 @@ test "render v2 format" { .location = "Munich", .current = .{ .temp_c = 12.0, + .feels_like_c = 12.0, + .feels_like_f = 12.0 * 1.8 + 32, .temp_f = 53.6, .condition = "Overcast", .weather_code = .clouds_overcast, @@ -103,6 +105,8 @@ test "render v2 format with imperial units" { .location = "Boston", .current = .{ .temp_c = 10.0, + .feels_like_c = 10.0, + .feels_like_f = 10.0 * 1.8 + 32, .temp_f = 50.0, .condition = "Clear", .weather_code = .clear, diff --git a/src/weather/MetNo.zig b/src/weather/MetNo.zig index a8759ac..516bee3 100644 --- a/src/weather/MetNo.zig +++ b/src/weather/MetNo.zig @@ -178,11 +178,18 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s else "N"; + // Parse forecast days from timeseries + const forecast = try parseForecastDays(allocator, timeseries.array.items); + + const feels_like_c = temp_c; // TODO: Calculate wind chill + return types.WeatherData{ .location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }), .current = .{ .temp_c = temp_c, .temp_f = temp_c * 9.0 / 5.0 + 32.0, + .feels_like_c = feels_like_c, + .feels_like_f = feels_like_c * 9.0 / 5.0 + 32.0, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .humidity = humidity, @@ -191,11 +198,174 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s .pressure_mb = pressure_mb, .precip_mm = 0.0, }, - .forecast = &.{}, + .forecast = forecast, .allocator = allocator, }; } +fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void { + for (forecast.items) |h| { + allocator.free(h.time); + allocator.free(h.condition); + } + forecast.clearRetainingCapacity(); +} + +fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value) ![]types.ForecastDay { + var days: std.ArrayList(types.ForecastDay) = .empty; + errdefer days.deinit(allocator); + + var current_date: ?[]const u8 = null; + var day_temps: std.ArrayList(f32) = .empty; + defer day_temps.deinit(allocator); + var day_hourly: std.ArrayList(types.HourlyForecast) = .empty; + var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty; + defer day_all_hours.deinit(allocator); + var day_symbol: ?[]const u8 = null; + + for (timeseries) |entry| { + const time_str = entry.object.get("time").?.string; + const date = time_str[0..10]; + const hour = time_str[11..13]; + + const data = entry.object.get("data") orelse continue; + const instant = data.object.get("instant") orelse continue; + const details_obj = instant.object.get("details") orelse continue; + const temp: f32 = @floatCast(details_obj.object.get("air_temperature").?.float); + const wind_ms = details_obj.object.get("wind_speed") orelse continue; + const wind_kph: f32 = @floatCast(wind_ms.float * 3.6); + + if (current_date == null or !std.mem.eql(u8, current_date.?, date)) { + // Save previous day if exists + if (current_date != null and day_temps.items.len > 0) { + var max_temp: f32 = day_temps.items[0]; + var min_temp: f32 = day_temps.items[0]; + for (day_temps.items) |t| { + if (t > max_temp) max_temp = t; + if (t < min_temp) min_temp = t; + } + + const symbol = day_symbol orelse "clearsky_day"; + + // Use preferred times if we have 4, otherwise pick 4 evenly spaced from all hours + const hourly_slice: []types.HourlyForecast = blk: { + if (day_hourly.items.len >= 4) { + const hrs = try day_hourly.toOwnedSlice(allocator); + clearHourlyForecast(allocator, &day_all_hours); + break :blk hrs; + } + + clearHourlyForecast(allocator, &day_all_hours); + + if (day_all_hours.items.len < 4) + break :blk try day_all_hours.toOwnedSlice(allocator); + // Pick 4 evenly spaced entries from day_all_hours + if (day_all_hours.items.len >= 4) { + const step = day_all_hours.items.len / 4; + var selected: std.ArrayList(types.HourlyForecast) = .empty; + try selected.append(allocator, day_all_hours.items[0]); + try selected.append(allocator, day_all_hours.items[step]); + try selected.append(allocator, day_all_hours.items[step * 2]); + try selected.append(allocator, day_all_hours.items[step * 3]); + const hrs = try selected.toOwnedSlice(allocator); + + // Free the rest + for (day_all_hours.items, 0..) |h, i| { + if (i != 0 and i != step and i != step * 2 and i != step * 3) { + allocator.free(h.time); + allocator.free(h.condition); + } + } + day_all_hours.clearRetainingCapacity(); + break :blk hrs; + } + break :blk try day_all_hours.toOwnedSlice(allocator); + }; + + try days.append(allocator, .{ + .date = try allocator.dupe(u8, current_date.?), + .max_temp_c = max_temp, + .min_temp_c = min_temp, + .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), + .weather_code = symbolCodeToWeatherCode(symbol), + .hourly = hourly_slice, + }); + + if (days.items.len >= 3) break; + } + + // Start new day + current_date = date; + day_temps.clearRetainingCapacity(); + day_all_hours.clearRetainingCapacity(); + day_symbol = null; + } + + try day_temps.append(allocator, temp); + + // Collect ALL hourly forecasts + const next_1h = data.object.get("next_1_hours"); + if (next_1h) |n1h| { + const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string; + const precip = if (n1h.object.get("details")) |det| blk: { + if (det.object.get("precipitation_amount")) |p| { + break :blk @as(f32, @floatCast(p.float)); + } + break :blk @as(f32, 0.0); + } else 0.0; + + try day_all_hours.append(allocator, .{ + .time = try allocator.dupe(u8, time_str[11..16]), + .temp_c = temp, + .feels_like_c = temp, + .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), + .weather_code = symbolCodeToWeatherCode(symbol_code), + .wind_kph = wind_kph, + .precip_mm = precip, + }); + } + + // Collect preferred hourly forecasts for 06:00, 12:00, 18:00, 00:00 + if (std.mem.eql(u8, hour, "06") or std.mem.eql(u8, hour, "12") or + std.mem.eql(u8, hour, "18") or std.mem.eql(u8, hour, "00")) { + if (next_1h) |n1h| { + const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string; + const precip = if (n1h.object.get("details")) |det| blk: { + if (det.object.get("precipitation_amount")) |p| { + break :blk @as(f32, @floatCast(p.float)); + } + break :blk @as(f32, 0.0); + } else 0.0; + + try day_hourly.append(allocator, .{ + .time = try allocator.dupe(u8, time_str[11..16]), + .temp_c = temp, + .feels_like_c = temp, + .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), + .weather_code = symbolCodeToWeatherCode(symbol_code), + .wind_kph = wind_kph, + .precip_mm = precip, + }); + } + } + + // Get symbol for the day + if (day_symbol == null) { + const next_6h = data.object.get("next_6_hours"); + if (next_6h) |n6h| { + if (n6h.object.get("summary")) |summary| { + day_symbol = summary.object.get("symbol_code").?.string; + } + } + } + } + + // Don't forget to deinit day_hourly if we didn't transfer ownership + day_hourly.deinit(allocator); + + return days.toOwnedSlice(allocator); +} + fn symbolCodeToWeatherCode(symbol: []const u8) types.WeatherCode { var it = std.mem.splitScalar(u8, symbol, '_'); const metno_weather_code = it.next().?; @@ -244,3 +414,55 @@ test "symbolCodeToWeatherCode" { try std.testing.expectEqual(types.WeatherCode.rain_light, symbolCodeToWeatherCode("lightrain")); try std.testing.expectEqual(types.WeatherCode.snow, symbolCodeToWeatherCode("snow")); } + +test "parseForecastDays extracts 3 days" { + const allocator = std.testing.allocator; + + const json_str = + \\{"properties":{"timeseries":[ + \\ {"time":"2025-12-20T00:00:00Z","data":{"instant":{"details":{"air_temperature":10.0,"wind_speed":5.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, + \\ {"time":"2025-12-20T06:00:00Z","data":{"instant":{"details":{"air_temperature":15.0,"wind_speed":6.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, + \\ {"time":"2025-12-20T12:00:00Z","data":{"instant":{"details":{"air_temperature":20.0,"wind_speed":7.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, + \\ {"time":"2025-12-20T18:00:00Z","data":{"instant":{"details":{"air_temperature":12.0,"wind_speed":5.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, + \\ {"time":"2025-12-21T00:00:00Z","data":{"instant":{"details":{"air_temperature":8.0,"wind_speed":8.0}},"next_6_hours":{"summary":{"symbol_code":"rain"}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}}, + \\ {"time":"2025-12-21T12:00:00Z","data":{"instant":{"details":{"air_temperature":14.0,"wind_speed":10.0}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}}, + \\ {"time":"2025-12-22T00:00:00Z","data":{"instant":{"details":{"air_temperature":5.0,"wind_speed":4.0}},"next_6_hours":{"summary":{"symbol_code":"snow"}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}}, + \\ {"time":"2025-12-22T12:00:00Z","data":{"instant":{"details":{"air_temperature":7.0,"wind_speed":3.0}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}}, + \\ {"time":"2025-12-23T00:00:00Z","data":{"instant":{"details":{"air_temperature":6.0,"wind_speed":2.0}}}} + \\]}} + ; + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{}); + defer parsed.deinit(); + + const properties = parsed.value.object.get("properties").?; + const timeseries = properties.object.get("timeseries").?; + + const forecast = try parseForecastDays(allocator, timeseries.array.items); + defer { + for (forecast) |day| { + allocator.free(day.date); + allocator.free(day.condition); + for (day.hourly) |hour| { + allocator.free(hour.time); + allocator.free(hour.condition); + } + allocator.free(day.hourly); + } + allocator.free(forecast); + } + + try std.testing.expectEqual(@as(usize, 3), forecast.len); + try std.testing.expectEqualStrings("2025-12-20", forecast[0].date); + try std.testing.expectEqual(@as(f32, 20.0), forecast[0].max_temp_c); + try std.testing.expectEqual(@as(f32, 10.0), forecast[0].min_temp_c); + try std.testing.expectEqual(types.WeatherCode.clear, forecast[0].weather_code); +} + +test "parseForecastDays handles empty timeseries" { + const allocator = std.testing.allocator; + const empty: []std.json.Value = &.{}; + const forecast = try parseForecastDays(allocator, empty); + defer allocator.free(forecast); + try std.testing.expectEqual(@as(usize, 0), forecast.len); +} diff --git a/src/weather/types.zig b/src/weather/types.zig index ddd7e06..eed03e5 100644 --- a/src/weather/types.zig +++ b/src/weather/types.zig @@ -105,6 +105,8 @@ pub const WeatherData = struct { pub const CurrentCondition = struct { temp_c: f32, temp_f: f32, + feels_like_c: f32, + feels_like_f: f32, condition: []const u8, weather_code: WeatherCode, humidity: u8, @@ -126,6 +128,7 @@ pub const ForecastDay = struct { pub const HourlyForecast = struct { time: []const u8, temp_c: f32, + feels_like_c: f32, condition: []const u8, weather_code: WeatherCode, wind_kph: f32,