add forecast data, feels like fields (leaking test)
This commit is contained in:
parent
8df2877644
commit
7fd9810c78
9 changed files with 272 additions and 5 deletions
|
|
@ -79,8 +79,8 @@ curl "http://localhost:8002/London?lang=us&format=2&m"
|
||||||
# Output: 51.5074,-0.1278: ☁️ 12°C 🌬️SW30km/h
|
# Output: 51.5074,-0.1278: ☁️ 12°C 🌬️SW30km/h
|
||||||
|
|
||||||
# Test from US IP (automatic detection)
|
# Test from US IP (automatic detection)
|
||||||
curl -H "X-Forwarded-For: 8.8.8.8" "http://localhost:8002/London?format=2"
|
curl -H "X-Forwarded-For: 1.1.1.1" "http://localhost:8002/London?format=2"
|
||||||
# Output: Uses imperial if 8.8.8.8 is detected as US IP
|
# Output: Uses imperial as 1.1.1.1 is detected as US IP
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation Updates
|
## Documentation Updates
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ Router
|
||||||
↓
|
↓
|
||||||
Request Handler
|
Request Handler
|
||||||
↓
|
↓
|
||||||
Cache Check
|
|
||||||
↓ (miss)
|
|
||||||
Location Resolver
|
Location Resolver
|
||||||
↓
|
↓
|
||||||
|
Provider interface cache check
|
||||||
|
↓ (miss)
|
||||||
Weather Provider (interface)
|
Weather Provider (interface)
|
||||||
├─ MetNo (default)
|
├─ MetNo (default)
|
||||||
└─ Mock (tests)
|
└─ Mock (tests)
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ test "render with imperial units" {
|
||||||
.location = "Chicago",
|
.location = "Chicago",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 10.0,
|
.temp_c = 10.0,
|
||||||
|
.feels_like_c = 10.0,
|
||||||
|
.feels_like_f = 10.0 * 1.8 + 32,
|
||||||
.temp_f = 50.0,
|
.temp_f = 50.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
@ -179,6 +181,8 @@ test "clear weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 20.0,
|
.temp_c = 20.0,
|
||||||
|
.feels_like_c = 20.0,
|
||||||
|
.feels_like_f = 20.0 * 1.8 + 32,
|
||||||
.temp_f = 68.0,
|
.temp_f = 68.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
@ -204,6 +208,8 @@ test "partly cloudy weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 18.0,
|
.temp_c = 18.0,
|
||||||
|
.feels_like_c = 18.0,
|
||||||
|
.feels_like_f = 18.0 * 1.8 + 32,
|
||||||
.temp_f = 64.0,
|
.temp_f = 64.0,
|
||||||
.condition = "Partly cloudy",
|
.condition = "Partly cloudy",
|
||||||
.weather_code = .clouds_few,
|
.weather_code = .clouds_few,
|
||||||
|
|
@ -229,6 +235,8 @@ test "cloudy weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 15.0,
|
.temp_c = 15.0,
|
||||||
|
.feels_like_c = 15.0,
|
||||||
|
.feels_like_f = 15.0 * 1.8 + 32,
|
||||||
.temp_f = 59.0,
|
.temp_f = 59.0,
|
||||||
.condition = "Cloudy",
|
.condition = "Cloudy",
|
||||||
.weather_code = .clouds_overcast,
|
.weather_code = .clouds_overcast,
|
||||||
|
|
@ -254,6 +262,8 @@ test "rain weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 12.0,
|
.temp_c = 12.0,
|
||||||
|
.feels_like_c = 12.0,
|
||||||
|
.feels_like_f = 12.0 * 1.8 + 32,
|
||||||
.temp_f = 54.0,
|
.temp_f = 54.0,
|
||||||
.condition = "Rain",
|
.condition = "Rain",
|
||||||
.weather_code = .rain_moderate,
|
.weather_code = .rain_moderate,
|
||||||
|
|
@ -278,6 +288,8 @@ test "thunderstorm weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 14.0,
|
.temp_c = 14.0,
|
||||||
|
.feels_like_c = 14.0,
|
||||||
|
.feels_like_f = 14.0 * 1.8 + 32,
|
||||||
.temp_f = 57.0,
|
.temp_f = 57.0,
|
||||||
.condition = "Thunderstorm",
|
.condition = "Thunderstorm",
|
||||||
.weather_code = .thunderstorm,
|
.weather_code = .thunderstorm,
|
||||||
|
|
@ -302,6 +314,8 @@ test "snow weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = -2.0,
|
.temp_c = -2.0,
|
||||||
|
.feels_like_c = -2.0,
|
||||||
|
.feels_like_f = -2.0 * 1.8 + 32,
|
||||||
.temp_f = 28.0,
|
.temp_f = 28.0,
|
||||||
.condition = "Snow",
|
.condition = "Snow",
|
||||||
.weather_code = .snow,
|
.weather_code = .snow,
|
||||||
|
|
@ -326,6 +340,8 @@ test "sleet weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 0.0,
|
.temp_c = 0.0,
|
||||||
|
.feels_like_c = 0.0,
|
||||||
|
.feels_like_f = 0.0 * 1.8 + 32,
|
||||||
.temp_f = 32.0,
|
.temp_f = 32.0,
|
||||||
.condition = "Sleet",
|
.condition = "Sleet",
|
||||||
.weather_code = .sleet,
|
.weather_code = .sleet,
|
||||||
|
|
@ -350,6 +366,8 @@ test "fog weather art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 8.0,
|
.temp_c = 8.0,
|
||||||
|
.feels_like_c = 8.0,
|
||||||
|
.feels_like_f = 8.0 * 1.8 + 32,
|
||||||
.temp_f = 46.0,
|
.temp_f = 46.0,
|
||||||
.condition = "Fog",
|
.condition = "Fog",
|
||||||
.weather_code = .fog,
|
.weather_code = .fog,
|
||||||
|
|
@ -374,6 +392,8 @@ test "unknown weather code art" {
|
||||||
.location = "Test",
|
.location = "Test",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 16.0,
|
.temp_c = 16.0,
|
||||||
|
.feels_like_c = 16.0,
|
||||||
|
.feels_like_f = 16.0 * 1.8 + 32,
|
||||||
.temp_f = 61.0,
|
.temp_f = 61.0,
|
||||||
.condition = "Unknown",
|
.condition = "Unknown",
|
||||||
.weather_code = .unknown,
|
.weather_code = .unknown,
|
||||||
|
|
@ -401,6 +421,8 @@ test "temperature matches between ansi and custom format" {
|
||||||
.location = "PDX",
|
.location = "PDX",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 13.1,
|
.temp_c = 13.1,
|
||||||
|
.feels_like_c = 13.1,
|
||||||
|
.feels_like_f = 13.1 * 1.8 + 32,
|
||||||
.temp_f = 55.6,
|
.temp_f = 55.6,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ test "render custom format with location and temp" {
|
||||||
.location = "London",
|
.location = "London",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 7.0,
|
.temp_c = 7.0,
|
||||||
|
.feels_like_c = 7.0,
|
||||||
|
.feels_like_f = 7.0 * 1.8 + 32,
|
||||||
.temp_f = 44.6,
|
.temp_f = 44.6,
|
||||||
.condition = "Overcast",
|
.condition = "Overcast",
|
||||||
.weather_code = .clouds_overcast,
|
.weather_code = .clouds_overcast,
|
||||||
|
|
@ -109,6 +111,8 @@ test "render custom format with newline" {
|
||||||
.location = "Paris",
|
.location = "Paris",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 10.0,
|
.temp_c = 10.0,
|
||||||
|
.feels_like_c = 10.0,
|
||||||
|
.feels_like_f = 10.0 * 1.8 + 32,
|
||||||
.temp_f = 50.0,
|
.temp_f = 50.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
@ -135,6 +139,8 @@ test "render custom format with humidity and pressure" {
|
||||||
.location = "Berlin",
|
.location = "Berlin",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 5.0,
|
.temp_c = 5.0,
|
||||||
|
.feels_like_c = 5.0,
|
||||||
|
.feels_like_f = 5.0 * 1.8 + 32,
|
||||||
.temp_f = 41.0,
|
.temp_f = 41.0,
|
||||||
.condition = "Cloudy",
|
.condition = "Cloudy",
|
||||||
.weather_code = .clouds_overcast,
|
.weather_code = .clouds_overcast,
|
||||||
|
|
@ -162,6 +168,8 @@ test "render custom format with imperial units" {
|
||||||
.location = "NYC",
|
.location = "NYC",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 10.0,
|
.temp_c = 10.0,
|
||||||
|
.feels_like_c = 10.0,
|
||||||
|
.feels_like_f = 10.0 * 1.8 + 32,
|
||||||
.temp_f = 50.0,
|
.temp_f = 50.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ test "render json format" {
|
||||||
.location = "London",
|
.location = "London",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 15.0,
|
.temp_c = 15.0,
|
||||||
|
.feels_like_c = 15.0,
|
||||||
|
.feels_like_f = 15.0 * 1.8 + 32,
|
||||||
.temp_f = 59.0,
|
.temp_f = 59.0,
|
||||||
.condition = "Partly cloudy",
|
.condition = "Partly cloudy",
|
||||||
.weather_code = .clouds_few,
|
.weather_code = .clouds_few,
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ test "format 1" {
|
||||||
.location = "London",
|
.location = "London",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 15.0,
|
.temp_c = 15.0,
|
||||||
|
.feels_like_c = 15.0,
|
||||||
|
.feels_like_f = 15.0 * 1.8 + 32,
|
||||||
.temp_f = 59.0,
|
.temp_f = 59.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
@ -146,6 +148,8 @@ test "custom format" {
|
||||||
.location = "London",
|
.location = "London",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 15.0,
|
.temp_c = 15.0,
|
||||||
|
.feels_like_c = 15.0,
|
||||||
|
.feels_like_f = 15.0 * 1.8 + 32,
|
||||||
.temp_f = 59.0,
|
.temp_f = 59.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
@ -170,6 +174,8 @@ test "format 2 with imperial units" {
|
||||||
.location = "Portland",
|
.location = "Portland",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 10.0,
|
.temp_c = 10.0,
|
||||||
|
.feels_like_c = 10.0,
|
||||||
|
.feels_like_f = 10.0 * 1.8 + 32,
|
||||||
.temp_f = 50.0,
|
.temp_f = 50.0,
|
||||||
.condition = "Cloudy",
|
.condition = "Cloudy",
|
||||||
.weather_code = .clouds_overcast,
|
.weather_code = .clouds_overcast,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ test "render v2 format" {
|
||||||
.location = "Munich",
|
.location = "Munich",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 12.0,
|
.temp_c = 12.0,
|
||||||
|
.feels_like_c = 12.0,
|
||||||
|
.feels_like_f = 12.0 * 1.8 + 32,
|
||||||
.temp_f = 53.6,
|
.temp_f = 53.6,
|
||||||
.condition = "Overcast",
|
.condition = "Overcast",
|
||||||
.weather_code = .clouds_overcast,
|
.weather_code = .clouds_overcast,
|
||||||
|
|
@ -103,6 +105,8 @@ test "render v2 format with imperial units" {
|
||||||
.location = "Boston",
|
.location = "Boston",
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = 10.0,
|
.temp_c = 10.0,
|
||||||
|
.feels_like_c = 10.0,
|
||||||
|
.feels_like_f = 10.0 * 1.8 + 32,
|
||||||
.temp_f = 50.0,
|
.temp_f = 50.0,
|
||||||
.condition = "Clear",
|
.condition = "Clear",
|
||||||
.weather_code = .clear,
|
.weather_code = .clear,
|
||||||
|
|
|
||||||
|
|
@ -178,11 +178,18 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s
|
||||||
else
|
else
|
||||||
"N";
|
"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{
|
return types.WeatherData{
|
||||||
.location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }),
|
.location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }),
|
||||||
.current = .{
|
.current = .{
|
||||||
.temp_c = temp_c,
|
.temp_c = temp_c,
|
||||||
.temp_f = temp_c * 9.0 / 5.0 + 32.0,
|
.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)),
|
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
|
||||||
.weather_code = symbolCodeToWeatherCode(symbol_code),
|
.weather_code = symbolCodeToWeatherCode(symbol_code),
|
||||||
.humidity = humidity,
|
.humidity = humidity,
|
||||||
|
|
@ -191,11 +198,174 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s
|
||||||
.pressure_mb = pressure_mb,
|
.pressure_mb = pressure_mb,
|
||||||
.precip_mm = 0.0,
|
.precip_mm = 0.0,
|
||||||
},
|
},
|
||||||
.forecast = &.{},
|
.forecast = forecast,
|
||||||
.allocator = allocator,
|
.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 {
|
fn symbolCodeToWeatherCode(symbol: []const u8) types.WeatherCode {
|
||||||
var it = std.mem.splitScalar(u8, symbol, '_');
|
var it = std.mem.splitScalar(u8, symbol, '_');
|
||||||
const metno_weather_code = it.next().?;
|
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.rain_light, symbolCodeToWeatherCode("lightrain"));
|
||||||
try std.testing.expectEqual(types.WeatherCode.snow, symbolCodeToWeatherCode("snow"));
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ pub const WeatherData = struct {
|
||||||
pub const CurrentCondition = struct {
|
pub const CurrentCondition = struct {
|
||||||
temp_c: f32,
|
temp_c: f32,
|
||||||
temp_f: f32,
|
temp_f: f32,
|
||||||
|
feels_like_c: f32,
|
||||||
|
feels_like_f: f32,
|
||||||
condition: []const u8,
|
condition: []const u8,
|
||||||
weather_code: WeatherCode,
|
weather_code: WeatherCode,
|
||||||
humidity: u8,
|
humidity: u8,
|
||||||
|
|
@ -126,6 +128,7 @@ pub const ForecastDay = struct {
|
||||||
pub const HourlyForecast = struct {
|
pub const HourlyForecast = struct {
|
||||||
time: []const u8,
|
time: []const u8,
|
||||||
temp_c: f32,
|
temp_c: f32,
|
||||||
|
feels_like_c: f32,
|
||||||
condition: []const u8,
|
condition: []const u8,
|
||||||
weather_code: WeatherCode,
|
weather_code: WeatherCode,
|
||||||
wind_kph: f32,
|
wind_kph: f32,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue