add forecast data, feels like fields (leaking test)

This commit is contained in:
Emil Lerch 2026-01-02 14:03:20 -08:00
parent 8df2877644
commit 7fd9810c78
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 272 additions and 5 deletions

View file

@ -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

View file

@ -23,10 +23,10 @@ Router
Request Handler
Cache Check
↓ (miss)
Location Resolver
Provider interface cache check
↓ (miss)
Weather Provider (interface)
├─ MetNo (default)
└─ Mock (tests)

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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);
}

View file

@ -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,