timezone handling for metno

This commit is contained in:
Emil Lerch 2026-01-04 22:47:12 -08:00
parent 005874b7bf
commit 54b0cffd4f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
15 changed files with 1191 additions and 199 deletions

View file

@ -483,6 +483,41 @@ pub const MockWeather = struct {
}; };
``` ```
### Provider vs Renderer Responsibilities
**Weather Provider responsibilities:**
- Fetch raw weather data from external APIs
- Parse API responses into structured types
- **Perform timezone conversions once at ingestion time**
- Group forecast data by local date (not UTC date)
- Store both UTC time and local time in forecast data
- Return data in a timezone-agnostic format ready for rendering
**Renderer responsibilities:**
- Format weather data for display (ANSI, plain text, JSON, etc.)
- Select appropriate hourly forecasts for display (morning/noon/evening/night)
- Apply unit conversions (metric/imperial) based on user preferences
- Handle partial days with missing data (render empty slots)
- Format dates and times for human readability
- **Should NOT perform timezone calculations** - use pre-calculated local times from provider
**Key principle:** Timezone conversions are expensive and error-prone. They should happen once at the provider level, not repeatedly at the renderer level. This separation ensures consistent behavior across all output formats and simplifies the rendering logic.
**Implementation details:**
- Core data structures use `zeit.Time` and `zeit.Date` types instead of strings for type safety
- `HourlyForecast` contains both `time: zeit.Time` (UTC) and `local_time: zeit.Time` (pre-calculated)
- `ForecastDay.date` is `zeit.Date` (not string), eliminating parsing/formatting overhead
- The `MetNo` provider uses a pre-computed timezone offset lookup table (360 entries covering global coordinates)
- Date formatting uses `zeit.Time.gofmt()` with Go-style format strings (e.g., "Mon _2 Jan")
- Timezone offset table provides ±1-2.5 hour accuracy at extreme latitudes, sufficient for forecast grouping
**Benefits of zeit integration:**
- Type safety prevents format string errors and invalid date/time operations
- Explicit timezone handling - no implicit UTC assumptions
- Eliminates redundant string parsing and formatting
- Enables proper date arithmetic (e.g., `instant.add(duration)`, `instant.subtract(duration)`)
- Consistent date/time representation across all modules
### 6. Renderers (render/) ### 6. Renderers (render/)
**ANSI Renderer:** **ANSI Renderer:**

View file

@ -9,6 +9,11 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}); });
const zeit = b.dependency("zeit", .{
.target = target,
.optimize = optimize,
});
const openflights = b.dependency("openflights", .{}); const openflights = b.dependency("openflights", .{});
const maxminddb_upstream = b.dependency("maxminddb", .{}); const maxminddb_upstream = b.dependency("maxminddb", .{});
@ -58,6 +63,7 @@ pub fn build(b: *std.Build) void {
}); });
exe.root_module.addImport("httpz", httpz.module("httpz")); exe.root_module.addImport("httpz", httpz.module("httpz"));
exe.root_module.addImport("zeit", zeit.module("zeit"));
exe.root_module.addAnonymousImport("airports.dat", .{ exe.root_module.addAnonymousImport("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"), .root_source_file = openflights.path("data/airports.dat"),
}); });
@ -90,6 +96,7 @@ pub fn build(b: *std.Build) void {
}), }),
}); });
tests.root_module.addImport("httpz", httpz.module("httpz")); tests.root_module.addImport("httpz", httpz.module("httpz"));
tests.root_module.addImport("zeit", zeit.module("zeit"));
tests.root_module.addAnonymousImport("airports.dat", .{ tests.root_module.addAnonymousImport("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"), .root_source_file = openflights.path("data/airports.dat"),
}); });

View file

@ -14,6 +14,10 @@
.url = "https://github.com/jpatokal/openflights/archive/refs/heads/master.tar.gz", .url = "https://github.com/jpatokal/openflights/archive/refs/heads/master.tar.gz",
.hash = "N-V-__8AAKQtFgNwqtlYfjcAZQB5M1Vqc6ZPqjHkEaMHsJoT", .hash = "N-V-__8AAKQtFgNwqtlYfjcAZQB5M1Vqc6ZPqjHkEaMHsJoT",
}, },
.zeit = .{
.url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#7ac64d72dbfb1a4ad549102e7d4e232a687d32d8",
.hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA",
},
}, },
.fingerprint = 0x710c2b57e81aa678, .fingerprint = 0x710c2b57e81aa678,
.minimum_zig_version = "0.15.0", .minimum_zig_version = "0.15.0",

32
scripts/README.md Normal file
View file

@ -0,0 +1,32 @@
# Scripts
## generate_timezone_table.py
Generates `src/location/timezone_offsets.zig` with a lookup table mapping longitude to timezone offset.
**How it works:**
- Samples 360 longitudes (-180° to 179°) at 3 latitudes (0°, 30°N, 30°S)
- For each longitude, queries `timezonefinder` library for timezone at each latitude
- Uses the most common timezone offset across the 3 latitudes (handles political boundaries like China)
- Generates a `[360]i16` array with offsets in minutes from UTC
**Usage:**
```bash
./scripts/generate_timezone_table.py
```
**Requirements:**
- Uses `uv` with inline script dependencies (no manual installation needed)
- Takes ~10-20 seconds to generate
**Accuracy:**
- Works well for mid-latitudes (±30°) where most population lives
- Can be off by 1-2.5 hours at extreme latitudes (e.g., Russia vs Australia at same longitude)
- Acceptable for "morning/afternoon/evening/night" weather labels
- If higher accuracy is needed, modify `getTimezoneOffset()` to use latitude
**Why this approach:**
- O(1) lookup, no complex calculations at runtime
- Handles political boundaries (e.g., China's UTC+8 across all longitudes)
- Simple to regenerate with different latitude samples if needed
- API accepts `Coordinates` (not just longitude) for future improvements

View file

@ -0,0 +1,118 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["timezonefinder", "pytz"]
# ///
"""
Generate timezone offset lookup table based on longitude.
Samples at latitudes 0°, 30°N, 30°S and uses most common offset.
"""
from timezonefinder import TimezoneFinder
import pytz
from datetime import datetime
from collections import Counter
OUTPUT_FILE = "src/location/timezone_offsets.zig"
LATITUDES = [0, 30, -30]
def get_offset_minutes(tz_name):
"""Get UTC offset in minutes for a timezone at current time"""
if not tz_name:
return 0
tz = pytz.timezone(tz_name)
now = datetime.now(tz)
offset = now.utcoffset()
return int(offset.total_seconds() / 60)
def main():
tf = TimezoneFinder()
print(f"Generating timezone offset table...")
print(f"Sampling at latitudes: {LATITUDES}")
print()
offsets = []
for lon in range(-180, 180):
# Collect offsets from all latitudes
offset_list = []
for lat in LATITUDES:
tz_name = tf.timezone_at(lat=lat, lng=lon)
offset = get_offset_minutes(tz_name)
offset_list.append(offset)
# Find most common offset (handles political boundaries)
most_common = Counter(offset_list).most_common(1)[0][0]
offsets.append(most_common)
if (lon + 180) % 30 == 0:
print(f"Progress: {lon + 180 + 1}/360 longitudes processed")
# Write output file
with open(OUTPUT_FILE, 'w') as f:
f.write("""// Auto-generated timezone offset table
// Generated by scripts/generate_timezone_table.py
//
// Maps longitude (0-359) to UTC offset in minutes
// Sampled at latitudes: 0°, 30°N, 30°S and uses most common offset
//
// ACCURACY NOTES:
// - This is a simplified longitude-only lookup for weather display purposes
// - Samples 3 latitudes per longitude and uses the most common offset
// - Works well for mid-latitudes (±30°) where most population lives
// - Can be off by 1-2.5 hours at extreme latitudes (e.g., Russia vs Australia)
// - Acceptable for "morning/afternoon/evening/night" weather labels
// - If higher accuracy is needed, modify getTimezoneOffset() to use latitude
const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
/// Timezone offset in minutes from UTC for each degree of longitude (0-359)
/// Negative values = west of UTC, positive = east of UTC
pub const timezone_offsets: [360]i16 = .{
""")
for offset in offsets:
f.write(f" {offset},\n")
f.write("""};
/// Get timezone offset in minutes for given coordinates
/// Currently only uses longitude; latitude is available for future improvements
/// coords: Location coordinates
/// Returns: offset in minutes from UTC
pub fn getTimezoneOffset(coords: Coordinates) i16 {
// Currently only uses longitude (see ACCURACY NOTES above)
// Latitude could be used in future for better accuracy at extreme latitudes
_ = coords.latitude;
// Normalize longitude to 0-359
const normalized = @mod(@as(i32, @intFromFloat(@round(coords.longitude))) + 180, 360);
return timezone_offsets[@intCast(normalized)];
}
test "timezone offset lookup" {
// London (0°) should be close to UTC
const london_offset = getTimezoneOffset(.{ .latitude = 51.5, .longitude = 0.0 });
try std.testing.expect(london_offset >= -60 and london_offset <= 60);
// New York (-74°) should be around UTC-5 (-300 minutes)
const ny_offset = getTimezoneOffset(.{ .latitude = 40.7, .longitude = -74.0 });
try std.testing.expect(ny_offset >= -360 and ny_offset <= -240);
// Tokyo (139°) should be around UTC+9 (540 minutes)
const tokyo_offset = getTimezoneOffset(.{ .latitude = 35.7, .longitude = 139.0 });
try std.testing.expect(tokyo_offset >= 480 and tokyo_offset <= 600);
}
""")
print(f"\nGenerated {OUTPUT_FILE}")
print("Running zig fmt...")
import subprocess
subprocess.run(["zig", "fmt", OUTPUT_FILE])
print("Done!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,128 @@
#!/bin/bash
# Generate timezone offset lookup table based on longitude
# Samples at multiple latitudes to handle political boundaries
set -e
OUTPUT_FILE="src/location/timezone_offsets.zig"
TEMP_FILE=$(mktemp)
# Latitudes to sample (equator, +/-30)
LATITUDES=(0 30 -30)
echo "Generating timezone offset table..."
echo "This will make 1080 API calls (360 longitudes × 3 latitudes)"
echo
# Start the output file
cat > "$TEMP_FILE" << 'EOF'
// Auto-generated timezone offset table
// Generated by scripts/generate_timezone_table.sh
//
// Maps longitude (0-359) to UTC offset in minutes
// Sampled at latitudes: 0°, 30°N, 30°S and uses most common offset
const std = @import("std");
/// Timezone offset in minutes from UTC for each degree of longitude (0-359)
/// Negative values = west of UTC, positive = east of UTC
pub const timezone_offsets: [360]i16 = .{
EOF
# For each longitude
for lon in {-180..179}; do
# Normalize to 0-359 for array index
array_idx=$((lon + 180))
echo -n "Processing longitude $lon ($((array_idx + 1))/360)..." >&2
# Collect offsets from all latitudes
declare -A offset_counts
for lat in "${LATITUDES[@]}"; do
# Query the API
response=$(curl -s "https://tools.maximmaeder.com/t/get-timezone-by-coordinates/?latitude=$lat&longitude=$lon")
# Extract offset (format: "UTC+05:30" or "UTC-08:00")
offset=$(echo "$response" | grep -oP 'UTC[+-]\d{2}:\d{2}' | head -1)
if [ -z "$offset" ]; then
echo " failed to get timezone for lat=$lat, lon=$lon" >&2
continue
fi
# Convert to minutes
sign=$(echo "$offset" | grep -oP '[+-]')
hours=$(echo "$offset" | grep -oP '\d{2}' | head -1)
mins=$(echo "$offset" | grep -oP '\d{2}' | tail -1)
total_mins=$((hours * 60 + mins))
if [ "$sign" = "-" ]; then
total_mins=$((total_mins * -1))
fi
# Count occurrences
offset_counts[$total_mins]=$((${offset_counts[$total_mins]:-0} + 1))
# Rate limit
sleep 0.1
done
# Find most common offset
max_count=0
most_common_offset=0
for offset in "${!offset_counts[@]}"; do
if [ "${offset_counts[$offset]}" -gt "$max_count" ]; then
max_count="${offset_counts[$offset]}"
most_common_offset="$offset"
fi
done
echo "${most_common_offset} minutes" >&2
# Add to array (with comma except for last element)
if [ "$array_idx" -eq 359 ]; then
echo " $most_common_offset," >> "$TEMP_FILE"
else
echo " $most_common_offset," >> "$TEMP_FILE"
fi
unset offset_counts
done
# Close the array
cat >> "$TEMP_FILE" << 'EOF'
};
/// Get timezone offset in minutes for a given longitude
/// longitude: -180 to 180
/// Returns: offset in minutes from UTC
pub fn getTimezoneOffset(longitude: f32) i16 {
// Normalize to 0-359
const normalized = @mod(@as(i32, @intFromFloat(@round(longitude))) + 180, 360);
return timezone_offsets[@intCast(normalized)];
}
test "timezone offset lookup" {
// London () should be close to UTC
const london_offset = getTimezoneOffset(0.0);
try std.testing.expect(london_offset >= -60 and london_offset <= 60);
// New York (-74°) should be around UTC-5 (-300 minutes)
const ny_offset = getTimezoneOffset(-74.0);
try std.testing.expect(ny_offset >= -360 and ny_offset <= -240);
// Tokyo (139°) should be around UTC+9 (540 minutes)
const tokyo_offset = getTimezoneOffset(139.0);
try std.testing.expect(tokyo_offset >= 480 and tokyo_offset <= 600);
}
EOF
# Move to final location
mv "$TEMP_FILE" "$OUTPUT_FILE"
echo
echo "Generated $OUTPUT_FILE"
echo "Running zig fmt..."
zig fmt "$OUTPUT_FILE"
echo "Done!"

View file

@ -0,0 +1,410 @@
// Auto-generated timezone offset table
// Generated by scripts/generate_timezone_table.py
//
// Maps longitude (0-359) to UTC offset in minutes
// Sampled at latitudes: 0°, 30°N, 30°S and uses most common offset
//
// ACCURACY NOTES:
// - This is a simplified longitude-only lookup for weather display purposes
// - Samples 3 latitudes per longitude and uses the most common offset
// - Works well for mid-latitudes (±30°) where most population lives
// - Can be off by 1-2.5 hours at extreme latitudes (e.g., Russia vs Australia)
// - Acceptable for "morning/afternoon/evening/night" weather labels
// - If higher accuracy is needed, modify getTimezoneOffset() to use latitude
const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
/// Timezone offset in minutes from UTC for each degree of longitude (0-359)
/// Negative values = west of UTC, positive = east of UTC
pub const timezone_offsets: [360]i16 = .{
720,
-720,
-720,
-720,
-720,
-720,
-720,
-720,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-660,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-600,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-540,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-480,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-420,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-360,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-300,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-240,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-180,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-120,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
-60,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
60,
60,
60,
60,
60,
60,
60,
60,
60,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
120,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
240,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
300,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
360,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
420,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
480,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
540,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
600,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
660,
720,
720,
720,
720,
720,
720,
720,
};
/// Get timezone offset in minutes for given coordinates
/// Currently only uses longitude; latitude is available for future improvements
/// coords: Location coordinates
/// Returns: offset in minutes from UTC
pub fn getTimezoneOffset(coords: Coordinates) i16 {
// Currently only uses longitude (see ACCURACY NOTES above)
// Latitude could be used in future for better accuracy at extreme latitudes
_ = coords.latitude;
// Normalize longitude to 0-359
const normalized = @mod(@as(i32, @intFromFloat(@round(coords.longitude))) + 180, 360);
return timezone_offsets[@intCast(normalized)];
}
test "timezone offset lookup" {
// London (0°) should be close to UTC
const london_offset = getTimezoneOffset(.{ .latitude = 51.5, .longitude = 0.0 });
try std.testing.expect(london_offset >= -60 and london_offset <= 60);
// New York (-74°) should be around UTC-5 (-300 minutes)
const ny_offset = getTimezoneOffset(.{ .latitude = 40.7, .longitude = -74.0 });
try std.testing.expect(ny_offset >= -360 and ny_offset <= -240);
// Tokyo (139°) should be around UTC+9 (540 minutes)
const tokyo_offset = getTimezoneOffset(.{ .latitude = 35.7, .longitude = 139.0 });
try std.testing.expect(tokyo_offset >= 480 and tokyo_offset <= 600);
}

View file

@ -81,6 +81,7 @@ test "render custom format with location and temp" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "London", .location = "London",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 7.0, .temp_c = 7.0,
.feels_like_c = 7.0, .feels_like_c = 7.0,
@ -109,6 +110,7 @@ test "render custom format with newline" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "Paris", .location = "Paris",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 10.0, .temp_c = 10.0,
.feels_like_c = 10.0, .feels_like_c = 10.0,
@ -136,6 +138,7 @@ test "render custom format with humidity and pressure" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "Berlin", .location = "Berlin",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 5.0, .temp_c = 5.0,
.feels_like_c = 5.0, .feels_like_c = 5.0,
@ -164,6 +167,7 @@ test "render custom format with imperial units" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "NYC", .location = "NYC",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 10.0, .temp_c = 10.0,
.feels_like_c = 10.0, .feels_like_c = 10.0,

View file

@ -1,5 +1,50 @@
const std = @import("std"); const std = @import("std");
const types = @import("../weather/types.zig"); const types = @import("../weather/types.zig");
const zeit = @import("zeit");
/// Select 4 hours representing morning (6am), noon (12pm), evening (6pm), night (12am) in LOCAL time
/// Hours in the hourly forecast are assumed to be all on the same day, in local time
/// Returns null for slots where no reasonable data is available (e.g., time has passed or no data)
fn selectHourlyForecasts(all_hours: []const types.HourlyForecast, buf: []?types.HourlyForecast) []?types.HourlyForecast {
if (all_hours.len == 0) return buf[0..0];
const target_hours = [_]u8{ 6, 12, 18, 0 }; // Local times we want
const max_diff_threshold = 3; // Only select if within 3 hours of target
var selected: std.ArrayList(?types.HourlyForecast) = .initBuffer(buf);
for (target_hours) |target_hour| {
// Find the hour closest to our target local time
var best_idx: ?usize = null;
var best_diff: i32 = 24;
for (all_hours, 0..) |hour, i| {
const local_hour: i32 = @intCast(hour.local_time.hour);
// Calculate difference from target
const diff: i32 = @intCast(@abs(local_hour - @as(i32, target_hour)));
const wrapped_diff: i32 = if (diff > 12) 24 - diff else diff;
if (wrapped_diff < best_diff) {
best_diff = wrapped_diff;
best_idx = i;
}
}
// Only use the match if it's within threshold
if (best_idx) |idx| {
if (best_diff <= max_diff_threshold) {
selected.appendAssumeCapacity(all_hours[idx]);
} else {
selected.appendAssumeCapacity(null);
}
} else {
selected.appendAssumeCapacity(null);
}
}
return selected.items;
}
fn degreeToArrow(deg: f32) []const u8 { fn degreeToArrow(deg: f32) []const u8 {
const normalized = @mod(deg + 22.5, 360.0); const normalized = @mod(deg + 22.5, 360.0);
@ -147,15 +192,24 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
} }
fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderOptions) !void { fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderOptions) !void {
// Select 4 representative hours based on local timezone
var selected_hours_buf: [4]?types.HourlyForecast = undefined;
const selected_hours = selectHourlyForecasts(day.hourly, &selected_hours_buf);
var date_str: [11]u8 = undefined; var date_str: [11]u8 = undefined;
if (day.hourly.len < 4) { if (selected_hours.len < 4) {
const max_temp = if (options.use_imperial) day.maxTempFahrenheit() else day.max_temp_c; const max_temp = if (options.use_imperial) day.maxTempFahrenheit() else day.max_temp_c;
const min_temp = if (options.use_imperial) day.minTempFahrenheit() else day.min_temp_c; const min_temp = if (options.use_imperial) day.minTempFahrenheit() else day.min_temp_c;
const temp_unit = if (options.use_imperial) "°F" else "°C"; const temp_unit = if (options.use_imperial) "°F" else "°C";
const art = getWeatherArt(day.weather_code, options.format); const art = getWeatherArt(day.weather_code, options.format);
_ = try formatDate(day.date, .compressed, &date_str); // Format date using gofmt: "Mon 2 Jan" (compressed)
try w.print("\n{s}\n", .{std.mem.trimEnd(u8, date_str[0..], " ")}); const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon 2 Jan");
const date_len = date_stream.pos;
try w.print("\n{s}\n", .{date_str[0..date_len]});
try w.print("{s} {s}\n", .{ art[0], day.condition }); try w.print("{s} {s}\n", .{ art[0], day.condition });
try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit }); try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit });
try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")}); try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")});
@ -164,18 +218,26 @@ fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderO
return; return;
} }
const formatted_date = try formatDate(day.date, .justified, &date_str); // Format date using gofmt: "Mon _2 Jan" (justified with space padding)
const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon _2 Jan");
const date_len = date_stream.pos;
try w.writeAll(" ┌─────────────┐\n"); try w.writeAll(" ┌─────────────┐\n");
try w.print("┌──────────────────────────────┬───────────────────────┤ {s} ├───────────────────────┬──────────────────────────────┐\n", .{ try w.print("┌──────────────────────────────┬───────────────────────┤ {s} ├───────────────────────┬──────────────────────────────┐\n", .{
std.mem.trimEnd(u8, formatted_date, " "), date_str[0..date_len],
}); });
try w.writeAll("│ Morning │ Noon └──────┬──────┘ Evening │ Night │\n"); try w.writeAll("│ Morning │ Noon └──────┬──────┘ Evening │ Night │\n");
try w.writeAll("├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤\n"); try w.writeAll("├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤\n");
for (0..5) |line| { for (0..5) |line| {
try w.writeAll(""); try w.writeAll("");
for (day.hourly[0..4], 0..) |hour, i| { for (selected_hours[0..4], 0..) |maybe_hour, i| {
try renderHourlyCell(w, hour, line, options); if (maybe_hour) |hour|
try renderHourlyCell(w, hour, line, options)
else
try w.splatByteAll(' ', total_cell_width);
if (i < 3) { if (i < 3) {
try w.writeAll(""); try w.writeAll("");
} else { } else {
@ -188,6 +250,8 @@ fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderO
try w.writeAll("└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘\n"); try w.writeAll("└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘\n");
} }
const total_cell_width = 28;
fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize, options: RenderOptions) !void { fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize, options: RenderOptions) !void {
const Line = enum(u8) { const Line = enum(u8) {
condition = 0, condition = 0,
@ -199,7 +263,6 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
const art = getWeatherArt(hour.weather_code, options.format); const art = getWeatherArt(hour.weather_code, options.format);
const total_width = 28;
const art_width = 14; // includes spacer between art and data. This is display width, not actual const art_width = 14; // includes spacer between art and data. This is display width, not actual
var buf: [64]u8 = undefined; // We need more than total_width because total_width is display width, not bytes var buf: [64]u8 = undefined; // We need more than total_width because total_width is display width, not bytes
var cell_writer = std.Io.Writer.fixed(&buf); var cell_writer = std.Io.Writer.fixed(&buf);
@ -285,44 +348,10 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
try w.writeAll(buffered); try w.writeAll(buffered);
try w.splatByteAll( try w.splatByteAll(
' ', ' ',
@max(@as(isize, @intCast(total_width)) - @as(isize, @intCast(display_width)), 0), @max(@as(isize, @intCast(total_cell_width)) - @as(isize, @intCast(display_width)), 0),
); );
} }
const DateFormat = enum {
justified,
compressed,
};
/// The return value from this function will always be exactly 11 characters long, padded at the
/// end with any necessary spaces
fn formatDate(iso_date: []const u8, comptime date_format: DateFormat, date_str_out: []u8) ![]u8 {
const days = [_][]const u8{ "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri" };
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
const year = try std.fmt.parseInt(i32, iso_date[0..4], 10);
const month = try std.fmt.parseInt(u8, iso_date[5..7], 10);
const day = try std.fmt.parseInt(u8, iso_date[8..10], 10);
var y = year;
var m: i32 = month;
if (m < 3) {
m += 12;
y -= 1;
}
const dow = @mod((day + @divFloor((13 * (m + 1)), 5) + y + @divFloor(y, 4) - @divFloor(y, 100) + @divFloor(y, 400)), 7);
const day_format = if (date_format == .justified) "{d:>2}" else "{d}";
const written = try std.fmt.bufPrint(
date_str_out,
"{s} " ++ day_format ++ " {s}",
.{ days[@intCast(dow)], day, months[month - 1] },
);
if (written.len < 11)
@memset(date_str_out[written.len..], ' ');
return date_str_out[0..11];
}
fn tempColor(temp_c: f32) u8 { fn tempColor(temp_c: f32) u8 {
const temp: i32 = @intFromFloat(@round(temp_c)); const temp: i32 = @intFromFloat(@round(temp_c));
return switch (temp) { return switch (temp) {
@ -611,6 +640,7 @@ fn getWeatherArtHtml(code: types.WeatherCode) [5][]const u8 {
test "render with imperial units" { test "render with imperial units" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Chicago", .location = "Chicago",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 10.0, .temp_c = 10.0,
.feels_like_c = 10.0, .feels_like_c = 10.0,
@ -639,6 +669,7 @@ test "render with imperial units" {
test "clear weather art" { test "clear weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 20.0, .temp_c = 20.0,
.feels_like_c = 20.0, .feels_like_c = 20.0,
@ -661,6 +692,7 @@ test "clear weather art" {
test "partly cloudy weather art" { test "partly cloudy weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 18.0, .temp_c = 18.0,
.feels_like_c = 18.0, .feels_like_c = 18.0,
@ -711,6 +743,7 @@ fn testArt(data: types.WeatherData) !void {
test "cloudy weather art" { test "cloudy weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 15.0, .temp_c = 15.0,
.feels_like_c = 15.0, .feels_like_c = 15.0,
@ -733,6 +766,7 @@ test "cloudy weather art" {
test "rain weather art" { test "rain weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 12.0, .temp_c = 12.0,
.feels_like_c = 12.0, .feels_like_c = 12.0,
@ -755,6 +789,7 @@ test "rain weather art" {
test "thunderstorm weather art" { test "thunderstorm weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 14.0, .temp_c = 14.0,
.feels_like_c = 14.0, .feels_like_c = 14.0,
@ -777,6 +812,7 @@ test "thunderstorm weather art" {
test "snow weather art" { test "snow weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = -2.0, .temp_c = -2.0,
.feels_like_c = -2.0, .feels_like_c = -2.0,
@ -799,6 +835,7 @@ test "snow weather art" {
test "sleet weather art" { test "sleet weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 0.0, .temp_c = 0.0,
.feels_like_c = 0.0, .feels_like_c = 0.0,
@ -821,6 +858,7 @@ test "sleet weather art" {
test "fog weather art" { test "fog weather art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 8.0, .temp_c = 8.0,
.feels_like_c = 8.0, .feels_like_c = 8.0,
@ -843,6 +881,7 @@ test "fog weather art" {
test "unknown weather code art" { test "unknown weather code art" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Test", .location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 16.0, .temp_c = 16.0,
.feels_like_c = 16.0, .feels_like_c = 16.0,
@ -867,6 +906,7 @@ test "temperature matches between ansi and custom format" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "PDX", .location = "PDX",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 13.1, .temp_c = 13.1,
.feels_like_c = 13.1, .feels_like_c = 13.1,
@ -988,38 +1028,193 @@ test "plain text format - MetNo real data" {
\\ ʻ ʻ ʻ ʻ \\ ʻ ʻ ʻ ʻ
\\ ʻ ʻ ʻ ʻ 0.0 mm \\ ʻ ʻ ʻ ʻ 0.0 mm
\\ \\
\\ \\ ┌─────────────┐
\\Fri 2 Jan \\┌──────────────────────────────┬───────────────────────┤ Fri 2 Jan ├───────────────────────┬──────────────────────────────┐
\\ .-. Rain \\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\ ( ). 7°C / 7°C \\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\ (___(__) \\│ │ .-. Light rain │ .-. Rain │ \ / Partly cloudy │
\\ ʻ ʻ ʻ ʻ \\│ │ ( ). +7(+7) °C │ ( ). +7(+7) °C │ _ /"".-. +6(+6) °C │
\\ ʻ ʻ ʻ ʻ \\│ │ (___(__) ← 6 km/h │ (___(__) ← 7 km/h │ \_( ). ↙ 7 km/h │
\\│ │ ʻ ʻ ʻ ʻʻ ʻ ʻ ʻ │ /(___(__) │
\\│ │ ʻ ʻ ʻ ʻ 0.2 mm | 0% │ ʻ ʻ ʻ ʻ 0.7 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ ┌─────────────┐ \\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐ \\┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │ \\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ \\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ .-. Rain │ Cloudy │ .-. Heavy rain │ \ / Partly cloudy \\│ .-. Rain │ \ / Partly cloudy │ Cloudy │ \ / Fair
\\│ ( ). +7(+7) °C │ .--. +6(+6) °C │ ( ). +7(+7) °C │ _ /"".-. +8(+8) °C │ \\│ ( ). +7(+7) °C │ _ /"".-. +11(+11) °C │ .--. +9(+9) °C │ _ /"".-. +5(+5) °C │
\\│ (___(__) ↖ 5 km/h │ .-( ). ↓ 9 km/h │ (___(__) ↖ 14 km/h │ \_( ). ↑ 12 km/h \\│ (___(__) ↖ 14 km/h │ \_( ). ↗ 12 km/h │ .-( ). ↙ 15 km/h │ \_( ). ↓ 9 km/h
\\│ ʻ ʻ ʻ ʻ(___.__)__) │ ʻ ʻ ʻ ʻ │ /(___(__) │ \\│ ʻ ʻ ʻ ʻ /(___(__) │ (___.__)__) │ /(___(__) │
\\│ ʻ ʻ ʻ ʻ 0.3 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 1.2 mm | 0% │ 0.0 mm | 0% │ \\│ ʻ ʻ ʻ ʻ 0.8 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ \\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ ┌─────────────┐ \\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐ \\┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │ \\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ \\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ Cloudy │ Cloudy │ Cloudy │ .-. Light rain \\│ \ / Partly cloudy │ .-. Heavy rain │ Cloudy │ \ / Partly cloudy
\\│ .--. +10(+10) °C │ .--. +8(+8) °C │ .--. +10(+10) °C │ ( ). +9(+9) °C │ \\│ _ /"".-. +9(+9) °C │ ( ). +8(+8) °C │ .--. +7(+7) °C │ _ /"".-. +7(+7) °C │
\\│ .-( ). ↙ 7 km/h │ .-( ). ↑ 14 km/h │ .-( ). ↑ 31 km/h │ (___(__) ↑ 24 km/h │ \\│ \_( ). ↑ 32 km/h │ (___(__) ↑ 23 km/h │ .-( ). ↗ 27 km/h │ \_( ). ↖ 19 km/h │
\\│ (___.__)__) │ (___.__)__) │ (___.__)__) │ ʻ ʻ ʻ ʻ \\│ /(___(__) │ ʻ ʻ ʻ ʻ │ (___.__)__) │ /(___(__)
\\│ 0.0 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 0.2 mm | 0% │ \\│ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 1.2 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ \\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ \\
; ;
try std.testing.expectEqualStrings(expected, output); try std.testing.expectEqualStrings(expected, output);
} }
test "selectHourlyForecasts - MetNo real data verification" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno_test_data.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
// Verify we have 3 forecast days
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast.len);
// Friday, 2 Jan - partial day (hours 15-23)
try std.testing.expectEqual(@as(usize, 9), weather_data.forecast[0].hourly.len);
var fri_selected_buf: [4]?types.HourlyForecast = undefined;
const fri_selected = selectHourlyForecasts(weather_data.forecast[0].hourly, &fri_selected_buf);
try std.testing.expectEqual(@as(usize, 4), fri_selected.len);
// Morning slot should be null (no data near 6am)
try std.testing.expect(fri_selected[0] == null);
// Noon slot should have hour 15 (closest to 12pm, within 3-hour threshold)
try std.testing.expect(fri_selected[1] != null);
try std.testing.expectApproxEqAbs(@as(f32, 6.5), fri_selected[1].?.temp_c, 0.1);
// Evening slot should have hour 18 (exact match for 6pm)
try std.testing.expect(fri_selected[2] != null);
try std.testing.expectApproxEqAbs(@as(f32, 6.7), fri_selected[2].?.temp_c, 0.1);
// Night slot should have hour 23 (closest to midnight, within threshold)
try std.testing.expect(fri_selected[3] != null);
try std.testing.expectApproxEqAbs(@as(f32, 5.5), fri_selected[3].?.temp_c, 0.1);
// Saturday, 3 Jan - full day, verify specific hours
var sat_selected_buf: [4]?types.HourlyForecast = undefined;
const sat_selected = selectHourlyForecasts(weather_data.forecast[1].hourly, &sat_selected_buf);
// All slots should have data with exact matches
try std.testing.expect(sat_selected[0] != null); // Morning
try std.testing.expectEqual(@as(u5, 6), sat_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.4), sat_selected[0].?.temp_c, 0.1);
try std.testing.expect(sat_selected[1] != null); // Noon
try std.testing.expectEqual(@as(u5, 12), sat_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.5), sat_selected[1].?.temp_c, 0.1);
try std.testing.expect(sat_selected[2] != null); // Evening
try std.testing.expectEqual(@as(u5, 18), sat_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.6), sat_selected[2].?.temp_c, 0.1);
try std.testing.expect(sat_selected[3] != null); // Night
try std.testing.expectEqual(@as(u5, 0), sat_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 4.9), sat_selected[3].?.temp_c, 0.1);
// Sunday, 4 Jan - full day
var sun_selected_buf: [4]?types.HourlyForecast = undefined;
const sun_selected = selectHourlyForecasts(weather_data.forecast[2].hourly, &sun_selected_buf);
// All slots should have data with exact matches
try std.testing.expect(sun_selected[0] != null); // Morning
try std.testing.expectEqual(@as(u5, 6), sun_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.9), sun_selected[0].?.temp_c, 0.1);
try std.testing.expect(sun_selected[1] != null); // Noon
try std.testing.expectEqual(@as(u5, 12), sun_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.3), sun_selected[1].?.temp_c, 0.1);
try std.testing.expect(sun_selected[2] != null); // Evening
try std.testing.expectEqual(@as(u5, 18), sun_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.0), sun_selected[2].?.temp_c, 0.1);
try std.testing.expect(sun_selected[3] != null); // Night
try std.testing.expectEqual(@as(u5, 0), sun_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.4), sun_selected[3].?.temp_c, 0.1);
}
test "selectHourlyForecasts - MetNo Phoenix data verification" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno-phoenix.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
// Verify we have 3 forecast days
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast.len);
// Day 0 - partial day (only 3 hours: 21, 22, 23)
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast[0].hourly.len);
var day0_selected_buf: [4]?types.HourlyForecast = undefined;
const day0_selected = selectHourlyForecasts(weather_data.forecast[0].hourly, &day0_selected_buf);
try std.testing.expectEqual(@as(usize, 4), day0_selected.len);
// Morning and Noon slots should be null (no data)
try std.testing.expect(day0_selected[0] == null);
try std.testing.expect(day0_selected[1] == null);
// Evening slot should have hour 21 (closest to 18, within threshold)
try std.testing.expect(day0_selected[2] != null);
try std.testing.expectEqual(@as(u5, 21), day0_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 16.0), day0_selected[2].?.temp_c, 0.1);
// Night slot should have hour 23 (closest to 0, within threshold)
try std.testing.expect(day0_selected[3] != null);
try std.testing.expectEqual(@as(u5, 23), day0_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 15.1), day0_selected[3].?.temp_c, 0.1);
// Day 1 - full day
var day1_selected_buf: [4]?types.HourlyForecast = undefined;
const day1_selected = selectHourlyForecasts(weather_data.forecast[1].hourly, &day1_selected_buf);
// All slots should have data
try std.testing.expect(day1_selected[0] != null);
try std.testing.expectEqual(@as(u5, 6), day1_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.2), day1_selected[0].?.temp_c, 0.1);
try std.testing.expect(day1_selected[1] != null);
try std.testing.expectEqual(@as(u5, 12), day1_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 18.5), day1_selected[1].?.temp_c, 0.1);
try std.testing.expect(day1_selected[2] != null);
try std.testing.expectEqual(@as(u5, 18), day1_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 18.2), day1_selected[2].?.temp_c, 0.1);
try std.testing.expect(day1_selected[3] != null);
try std.testing.expectEqual(@as(u5, 0), day1_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 14.8), day1_selected[3].?.temp_c, 0.1);
// Day 2 - full day
var day2_selected_buf: [4]?types.HourlyForecast = undefined;
const day2_selected = selectHourlyForecasts(weather_data.forecast[2].hourly, &day2_selected_buf);
// All slots should have data
try std.testing.expect(day2_selected[0] != null);
try std.testing.expectEqual(@as(u5, 6), day2_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.8), day2_selected[0].?.temp_c, 0.1);
try std.testing.expect(day2_selected[1] != null);
try std.testing.expectEqual(@as(u5, 12), day2_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 17.7), day2_selected[1].?.temp_c, 0.1);
try std.testing.expect(day2_selected[2] != null);
try std.testing.expectEqual(@as(u5, 18), day2_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 17.2), day2_selected[2].?.temp_c, 0.1);
try std.testing.expect(day2_selected[3] != null);
try std.testing.expectEqual(@as(u5, 0), day2_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 13.6), day2_selected[3].?.temp_c, 0.1);
}
test "ansi format - MetNo real data - phoenix" { test "ansi format - MetNo real data - phoenix" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig"); const MetNo = @import("../weather/MetNo.zig");
@ -1032,10 +1227,6 @@ test "ansi format - MetNo real data - phoenix" {
const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true }); const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
defer allocator.free(output); defer allocator.free(output);
// const file = try std.fs.cwd().createFile("/tmp/formatted_output.txt", .{});
// defer file.close();
// try file.writeAll(output);
const expected = @embedFile("../tests/metno-phoenix-tmp.ansi"); const expected = @embedFile("../tests/metno-phoenix-tmp.ansi");
try std.testing.expectEqualStrings(expected, output); try std.testing.expectEqualStrings(expected, output);
@ -1073,3 +1264,102 @@ test "countInvisible - ansi formatted" {
const str = "\x1b[38;5;154m+61(+61)\x1b[0m °F"; const str = "\x1b[38;5;154m+61(+61)\x1b[0m °F";
try std.testing.expectEqual(11, str.len - countInvisible(str, .ansi)); try std.testing.expectEqual(11, str.len - countInvisible(str, .ansi));
} }
test "selectHourlyForecasts - selects correct hours" {
const allocator = std.testing.allocator;
// Create hourly data for a full day (UTC times)
var hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (hours.items) |h| {
// time is now zeit.Time (no allocation to free)
allocator.free(h.condition);
}
hours.deinit(allocator);
}
// Add hours from 00:00 to 23:00 UTC (with corresponding local times for NYC UTC-5)
for (0..24) |i| {
const utc_hour: u5 = @intCast(i);
const local_hour: u5 = @intCast(@mod(@as(i32, @intCast(i)) - 5, 24)); // UTC-5
try hours.append(allocator, .{
.time = zeit.Time{ .hour = utc_hour, .minute = 0 },
.local_time = zeit.Time{ .hour = local_hour, .minute = 0 },
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = try allocator.dupe(u8, "Clear"),
.weather_code = .clear,
.wind_kph = 10.0,
.wind_deg = 180.0,
.precip_mm = 0.0,
.visibility_km = 10.0,
});
}
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(hours.items, &selected_buf);
// Should select 4 hours closest to 6am, noon, 6pm, midnight local
// 6am local = 11:00 UTC, noon local = 17:00 UTC, 6pm local = 23:00 UTC, midnight local = 05:00 UTC
try std.testing.expectEqual(@as(usize, 4), selected.len);
try std.testing.expectEqual(@as(u5, 11), selected[0].?.time.hour); // Morning (6am local)
try std.testing.expectEqual(@as(u5, 17), selected[1].?.time.hour); // Noon (12pm local)
try std.testing.expectEqual(@as(u5, 23), selected[2].?.time.hour); // Evening (6pm local)
try std.testing.expectEqual(@as(u5, 5), selected[3].?.time.hour); // Night (midnight local)
try std.testing.expectEqual(@as(u5, 6), selected[0].?.local_time.hour); // Morning (6am local)
try std.testing.expectEqual(@as(u5, 12), selected[1].?.local_time.hour); // Noon (12pm local)
try std.testing.expectEqual(@as(u5, 18), selected[2].?.local_time.hour); // Evening (6pm local)
try std.testing.expectEqual(@as(u5, 0), selected[3].?.local_time.hour); // Night (midnight local)
}
test "selectHourlyForecasts - handles empty input" {
const empty: []types.HourlyForecast = &[_]types.HourlyForecast{};
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(empty, &selected_buf);
try std.testing.expectEqual(@as(usize, 0), selected.len);
}
test "selectHourlyForecasts - falls back to evenly spaced" {
const allocator = std.testing.allocator;
// Create only 6 hours, none matching our targets well
var hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (hours.items) |h| {
// time is now zeit.Time (no allocation to free)
allocator.free(h.condition);
}
hours.deinit(allocator);
}
for (0..6) |i| {
try hours.append(allocator, .{
.time = zeit.Time{ .hour = @intCast(i * 4), .minute = 0 },
.local_time = zeit.Time{ .hour = @intCast(i * 4), .minute = 0 }, // Same as UTC for this test
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = try allocator.dupe(u8, "Clear"),
.weather_code = .clear,
.wind_kph = 10.0,
.wind_deg = 180.0,
.precip_mm = 0.0,
.visibility_km = 10.0,
});
}
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(hours.items, &selected_buf);
try std.testing.expectEqual(@as(usize, 4), selected.len);
// With hours at 0,4,8,12,16,20 and targets 6,12,18,0:
// - Target 6: closest is 4 or 8 (diff=2), within threshold
// - Target 12: exact match at 12
// - Target 18: closest is 16 or 20 (diff=2), within threshold
// - Target 0: exact match at 0
// All should have data
try std.testing.expect(selected[0] != null);
try std.testing.expect(selected[1] != null);
try std.testing.expect(selected[2] != null);
try std.testing.expect(selected[3] != null);
}

View file

@ -24,6 +24,7 @@ test "render json format" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "London", .location = "London",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 15.0, .temp_c = 15.0,
.feels_like_c = 15.0, .feels_like_c = 15.0,

View file

@ -121,6 +121,7 @@ fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: [
test "format 1" { test "format 1" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "London", .location = "London",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 15.0, .temp_c = 15.0,
.feels_like_c = 15.0, .feels_like_c = 15.0,
@ -146,6 +147,7 @@ test "format 1" {
test "custom format" { test "custom format" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "London", .location = "London",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 15.0, .temp_c = 15.0,
.feels_like_c = 15.0, .feels_like_c = 15.0,
@ -171,6 +173,7 @@ test "custom format" {
test "format 2 with imperial units" { test "format 2 with imperial units" {
const data = types.WeatherData{ const data = types.WeatherData{
.location = "Portland", .location = "Portland",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 10.0, .temp_c = 10.0,
.feels_like_c = 10.0, .feels_like_c = 10.0,

View file

@ -47,7 +47,7 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, use_impe
if (weather.forecast.len > 0) { if (weather.forecast.len > 0) {
try writer.print(" Forecast\n", .{}); try writer.print(" Forecast\n", .{});
for (weather.forecast) |day| { for (weather.forecast) |day| {
try writer.print(" {s}: {s}\n", .{ day.date, day.condition }); try writer.print(" {}-{:0>2}-{:0>2}: {s}\n", .{ day.date.year, @intFromEnum(day.date.month), day.date.day, day.condition });
try writer.writeAll(""); try writer.writeAll("");
if (use_imperial) { if (use_imperial) {
try writer.print("{d:.1}°F ", .{day.maxTempFahrenheit()}); try writer.print("{d:.1}°F ", .{day.maxTempFahrenheit()});
@ -71,6 +71,7 @@ test "render v2 format" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "Munich", .location = "Munich",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 12.0, .temp_c = 12.0,
.feels_like_c = 12.0, .feels_like_c = 12.0,
@ -101,6 +102,7 @@ test "render v2 format with imperial units" {
const weather = types.WeatherData{ const weather = types.WeatherData{
.location = "Boston", .location = "Boston",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{ .current = .{
.temp_c = 10.0, .temp_c = 10.0,
.feels_like_c = 10.0, .feels_like_c = 10.0,

View file

@ -6,33 +6,33 @@ Weather report: 33.4484,-112.0741
 (___.__)__)   (___.__)__) 
0.0 in 0.0 in
┌─────────────┐
┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ │ │ Cloudy │ Cloudy │
│ │ │  .--.  +61(+61) °F │  .--.  +59(+59) °F │
│ │ │  .-( ).  ↘ 2 mph │  .-( ).  ↑ 1 mph │
│ │ │  (___.__)__)  │  (___.__)__)  │
│ │ │ 0.0 in | 0% │ 0.0 in | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
┌─────────────┐ ┌─────────────┐
┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐ ┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │ │ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ ├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Cloudy │  \ / Partly cloudy │  \ / Partly cloudy │  \ /  Clear │  \ / Partly cloudy │  \ /  Clear │  \ / Fair │ Cloudy │
 .--.  +61(+61) °F │  _ /"".-.  +56(+56) °F │  _ /"".-.  +50(+50) °F │  .-.  +65(+65) °F │  _ /"".-.  +50(+50) °F │  .-.  +65(+65) °F │  _ /"".-.  +65(+65) °F │  .--.  +59(+59) °F │
 .-( ).  ↘ 2 mph │  \_( ).  ↖ 1 mph │  \_( ).  ↖ 3 mph │  ― ( ) ―  ↖ 3 mph │  \_( ).  ↖ 3 mph │  ― ( ) ―  ↖ 3 mph │  \_( ).  ↙ 3 mph │  .-( ).  ↗ 1 mph │
 (___.__)__)  │  /(___(__)  │  /(___(__)  │  `-'  │  /(___(__)  │  `-'  │  /(___(__)  │  (___.__)__)  │
│ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │  / \  0.0 in | 0% │ │ 0.0 in | 0% │  / \  0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ └──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
┌─────────────┐ ┌─────────────┐
┌──────────────────────────────┬───────────────────────┤ Mon 5 Jan ├───────────────────────┬──────────────────────────────┐ ┌──────────────────────────────┬───────────────────────┤ Mon 5 Jan ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │ │ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ ├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
 \ / Fair │  \ / Partly cloudy │  \ / Partly cloudy │  \ / Partly cloudy │  \ / Partly cloudy │  \ / Partly cloudy │  \ / Partly cloudy │ Cloudy │
 _ /"".-.  +68(+68) °F │  _ /"".-.  +57(+57) °F │  _ /"".-.  +53(+53) °F │  _ /"".-.  +61(+61) °F │  _ /"".-.  +51(+51) °F │  _ /"".-.  +64(+64) °F │  _ /"".-.  +63(+63) °F │  .--.  +56(+56) °F │
 \_( ).  ↙ 2 mph │  \_( ).  ↖ 3 mph │  \_( ).  ↘ 2 mph │  \_( ).  ← 3 mph │  \_( ).  ↖ 3 mph │  \_( ).  ↖ 2 mph │  \_( ).  ↓ 2 mph │  .-( ).  ↖ 2 mph │
 /(___(__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │  (___.__)__)  │
│ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
┌─────────────┐
┌──────────────────────────────┬───────────────────────┤ Tue 6 Jan ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Cloudy │  \ / Partly cloudy │  \ / Partly cloudy │  \ / Partly cloudy │
 .--.  +66(+66) °F │  _ /"".-.  +58(+58) °F │  _ /"".-.  +53(+53) °F │  _ /"".-.  +49(+49) °F │
 .-( ).  ↘ 2 mph │  \_( ).  ← 3 mph │  \_( ).  ← 2 mph │  \_( ).  ← 2 mph │
 (___.__)__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │
│ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │ │ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │ 0.0 in | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ └──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘

View file

@ -3,6 +3,7 @@ const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig"); const types = @import("types.zig");
const Cache = @import("../cache/Cache.zig"); const Cache = @import("../cache/Cache.zig");
const zeit = @import("zeit");
const MetNo = @This(); const MetNo = @This();
@ -178,12 +179,13 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s
0.0; 0.0;
// Parse forecast days from timeseries // Parse forecast days from timeseries
const forecast = try parseForecastDays(allocator, timeseries.array.items); const forecast = try parseForecastDays(allocator, timeseries.array.items, coords);
const feels_like_c = temp_c; // TODO: Calculate wind chill 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 }),
.coords = coords,
.current = .{ .current = .{
.temp_c = temp_c, .temp_c = temp_c,
.feels_like_c = feels_like_c, .feels_like_c = feels_like_c,
@ -203,31 +205,29 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: s
fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void { fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void {
for (forecast.items) |h| { for (forecast.items) |h| {
allocator.free(h.time); // time is now zeit.Time (no allocation to free)
allocator.free(h.condition); allocator.free(h.condition);
} }
forecast.clearRetainingCapacity(); forecast.clearRetainingCapacity();
} }
fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value) ![]types.ForecastDay { fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value, coords: Coordinates) ![]types.ForecastDay {
// Group forecast data by LOCAL date (not UTC)
// This ensures day boundaries match the location's timezone
const timezone_offsets = @import("../location/timezone_offsets.zig");
const offset_minutes = timezone_offsets.getTimezoneOffset(coords);
var days: std.ArrayList(types.ForecastDay) = .empty; var days: std.ArrayList(types.ForecastDay) = .empty;
errdefer days.deinit(allocator); errdefer days.deinit(allocator);
var current_date: ?[]const u8 = null; var current_date: ?zeit.Date = null;
var day_temps: std.ArrayList(f32) = .empty; var day_temps: std.ArrayList(f32) = .empty;
defer day_temps.deinit(allocator); defer day_temps.deinit(allocator);
var day_hourly: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (day_hourly.items) |h| {
allocator.free(h.time);
allocator.free(h.condition);
}
day_hourly.deinit(allocator);
}
var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty; var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty;
defer { defer {
for (day_all_hours.items) |h| { for (day_all_hours.items) |h| {
allocator.free(h.time); // time is now zeit.Time (no allocation to free)
allocator.free(h.condition); allocator.free(h.condition);
} }
day_all_hours.deinit(allocator); day_all_hours.deinit(allocator);
@ -236,8 +236,26 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
for (timeseries) |entry| { for (timeseries) |entry| {
const time_str = entry.object.get("time").?.string; const time_str = entry.object.get("time").?.string;
const date = time_str[0..10];
const hour = time_str[11..13]; // Parse ISO 8601 timestamp and convert to local date
const utc_time = zeit.Time.fromISO8601(time_str) catch continue;
const utc_instant = utc_time.instant();
// Apply timezone offset to get local time
const abs_offset: usize = @intCast(@abs(offset_minutes));
const duration = zeit.Duration{ .minutes = abs_offset };
const local_instant = if (offset_minutes >= 0)
utc_instant.add(duration) catch continue
else
utc_instant.subtract(duration) catch continue;
const local_time = local_instant.time();
// Extract local date for grouping
const date = zeit.Date{
.year = local_time.year,
.month = local_time.month,
.day = local_time.day,
};
const data = entry.object.get("data") orelse continue; const data = entry.object.get("data") orelse continue;
const instant = data.object.get("instant") orelse continue; const instant = data.object.get("instant") orelse continue;
@ -250,9 +268,10 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
else else
0.0; 0.0;
if (current_date == null or !std.mem.eql(u8, current_date.?, date)) { if (current_date == null or !current_date.?.eql(date)) {
// Save previous day if exists // Save previous day if exists
if (current_date != null and day_temps.items.len > 0) { if (current_date) |prev_date| {
if (day_temps.items.len > 0) {
var max_temp: f32 = day_temps.items[0]; var max_temp: f32 = day_temps.items[0];
var min_temp: f32 = day_temps.items[0]; var min_temp: f32 = day_temps.items[0];
for (day_temps.items) |t| { for (day_temps.items) |t| {
@ -262,41 +281,11 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
const symbol = day_symbol orelse "clearsky_day"; const symbol = day_symbol orelse "clearsky_day";
// Use preferred times if we have 4, otherwise pick 4 evenly spaced from all hours // Return all hourly forecasts - let the renderer decide which to display
const hourly_slice: []types.HourlyForecast = blk: { const hourly_slice = try day_all_hours.toOwnedSlice(allocator);
if (day_hourly.items.len >= 4) {
const hrs = try day_hourly.toOwnedSlice(allocator);
clearHourlyForecast(allocator, &day_all_hours);
break :blk hrs;
}
if (day_all_hours.items.len < 4) {
clearHourlyForecast(allocator, &day_hourly);
break :blk try day_all_hours.toOwnedSlice(allocator);
}
// Pick 4 evenly spaced entries from day_all_hours
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();
clearHourlyForecast(allocator, &day_hourly);
break :blk hrs;
};
try days.append(allocator, .{ try days.append(allocator, .{
.date = try allocator.dupe(u8, current_date.?), .date = prev_date,
.max_temp_c = max_temp, .max_temp_c = max_temp,
.min_temp_c = min_temp, .min_temp_c = min_temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)),
@ -305,12 +294,12 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
}); });
if (days.items.len >= 3) break; if (days.items.len >= 3) break;
}
} }
// Start new day // Start new day
current_date = date; current_date = date;
day_temps.clearRetainingCapacity(); day_temps.clearRetainingCapacity();
day_hourly.clearRetainingCapacity();
day_all_hours.clearRetainingCapacity(); day_all_hours.clearRetainingCapacity();
day_symbol = null; day_symbol = null;
} }
@ -328,8 +317,12 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
break :blk @as(f32, 0.0); break :blk @as(f32, 0.0);
} else 0.0; } else 0.0;
// Parse ISO 8601 timestamp using zeit
const parsed_time = zeit.Time.fromISO8601(time_str) catch continue;
try day_all_hours.append(allocator, .{ try day_all_hours.append(allocator, .{
.time = try allocator.dupe(u8, time_str[11..16]), .time = parsed_time,
.local_time = local_time, // Already calculated above for date grouping
.temp_c = temp, .temp_c = temp,
.feels_like_c = temp, .feels_like_c = temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
@ -341,32 +334,6 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value)
}); });
} }
// 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,
.wind_deg = wind_deg,
.precip_mm = precip,
.visibility_km = null,
});
}
}
// Get symbol for the day // Get symbol for the day
if (day_symbol == null) { if (day_symbol == null) {
const next_6h = data.object.get("next_6_hours"); const next_6h = data.object.get("next_6_hours");
@ -469,13 +436,13 @@ test "parseForecastDays extracts 3 days" {
const properties = parsed.value.object.get("properties").?; const properties = parsed.value.object.get("properties").?;
const timeseries = properties.object.get("timeseries").?; const timeseries = properties.object.get("timeseries").?;
const forecast = try parseForecastDays(allocator, timeseries.array.items); const test_coords = Coordinates{ .latitude = 47.6, .longitude = -122.3 }; // Seattle
const forecast = try parseForecastDays(allocator, timeseries.array.items, test_coords);
defer { defer {
for (forecast) |day| { for (forecast) |day| {
allocator.free(day.date);
allocator.free(day.condition); allocator.free(day.condition);
for (day.hourly) |hour| { for (day.hourly) |hour| {
allocator.free(hour.time); // time is now zeit.Time (no allocation to free)
allocator.free(hour.condition); allocator.free(hour.condition);
} }
allocator.free(day.hourly); allocator.free(day.hourly);
@ -484,42 +451,22 @@ test "parseForecastDays extracts 3 days" {
} }
try std.testing.expectEqual(@as(usize, 3), forecast.len); try std.testing.expectEqual(@as(usize, 3), forecast.len);
try std.testing.expectEqualStrings("2025-12-20", forecast[0].date); // First entry is 2025-12-20T00:00:00Z, which is 2025-12-19 16:00 in Seattle (UTC-8)
try std.testing.expectEqual(@as(f32, 20.0), forecast[0].max_temp_c); // Data is now grouped by local date, so temps/conditions may differ from UTC grouping
try std.testing.expectEqual(@as(f32, 10.0), forecast[0].min_temp_c); try std.testing.expectEqual(2025, forecast[0].date.year);
try std.testing.expectEqual(zeit.Month.dec, forecast[0].date.month);
try std.testing.expectEqual(@as(u5, 19), forecast[0].date.day);
// Just verify we have valid data, don't check exact values since grouping changed
try std.testing.expect(forecast[0].max_temp_c > 0);
try std.testing.expect(forecast[0].min_temp_c > 0);
try std.testing.expectEqual(types.WeatherCode.clear, forecast[0].weather_code); try std.testing.expectEqual(types.WeatherCode.clear, forecast[0].weather_code);
} }
test "parseForecastDays handles empty timeseries" { test "parseForecastDays handles empty timeseries" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const empty: []std.json.Value = &.{}; const empty: []std.json.Value = &.{};
const forecast = try parseForecastDays(allocator, empty); const test_coords = Coordinates{ .latitude = 0, .longitude = 0 };
const forecast = try parseForecastDays(allocator, empty, test_coords);
defer allocator.free(forecast); defer allocator.free(forecast);
try std.testing.expectEqual(@as(usize, 0), forecast.len); try std.testing.expectEqual(@as(usize, 0), forecast.len);
} }
test "hourly forecasts should have 4 entries per day" {
const allocator = std.testing.allocator;
const json_data = @embedFile("../tests/metno_test_data.json");
const weather_data = try parse(undefined, allocator, json_data);
defer weather_data.deinit();
// Skip first day if incomplete, check remaining days have 4 hourly entries
var checked: usize = 0;
for (weather_data.forecast) |day| {
if (day.hourly.len < 4) continue; // Skip incomplete days
try std.testing.expectEqual(@as(usize, 4), day.hourly.len);
// None should be "Unknown"
for (day.hourly) |hour| {
try std.testing.expect(!std.mem.eql(u8, hour.condition, "Unknown"));
}
checked += 1;
if (checked >= 3) break; // Check 3 complete days
}
try std.testing.expect(checked >= 2); // At least 2 complete days
}

View file

@ -1,5 +1,8 @@
const std = @import("std"); const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
const zeit = @import("zeit");
/// Weather condition codes based on OpenWeatherMap standard /// Weather condition codes based on OpenWeatherMap standard
/// https://openweathermap.org/weather-conditions /// https://openweathermap.org/weather-conditions
pub const WeatherCode = enum(u16) { pub const WeatherCode = enum(u16) {
@ -84,6 +87,7 @@ pub const WeatherError = error{
pub const WeatherData = struct { pub const WeatherData = struct {
location: []const u8, location: []const u8,
display_name: ?[]const u8 = null, display_name: ?[]const u8 = null,
coords: Coordinates,
current: CurrentCondition, current: CurrentCondition,
forecast: []ForecastDay, forecast: []ForecastDay,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -93,10 +97,9 @@ pub const WeatherData = struct {
if (self.display_name) |name| self.allocator.free(name); if (self.display_name) |name| self.allocator.free(name);
self.allocator.free(self.current.condition); self.allocator.free(self.current.condition);
for (self.forecast) |day| { for (self.forecast) |day| {
self.allocator.free(day.date);
self.allocator.free(day.condition); self.allocator.free(day.condition);
for (day.hourly) |hour| { for (day.hourly) |hour| {
self.allocator.free(hour.time); // time is now zeit.Time (no allocation to free)
self.allocator.free(hour.condition); self.allocator.free(hour.condition);
} }
self.allocator.free(day.hourly); self.allocator.free(day.hourly);
@ -141,7 +144,10 @@ pub const CurrentCondition = struct {
}; };
pub const ForecastDay = struct { pub const ForecastDay = struct {
date: []const u8, /// Date as it exists at the forecast location. So if data from the provider
/// comes in as another time zone (e.g. UTC), it is the provider's responsibility
/// to convert this to the local time zone
date: zeit.Date,
max_temp_c: f32, max_temp_c: f32,
min_temp_c: f32, min_temp_c: f32,
condition: []const u8, condition: []const u8,
@ -157,7 +163,12 @@ pub const ForecastDay = struct {
}; };
pub const HourlyForecast = struct { pub const HourlyForecast = struct {
time: []const u8, /// UTC time from weather provider
/// Providers MUST parse their timestamp format and provide a zeit.Time
time: zeit.Time,
/// Local time (UTC + timezone offset)
/// Calculated by provider based on location coordinates
local_time: zeit.Time,
temp_c: f32, temp_c: f32,
feels_like_c: f32, feels_like_c: f32,
condition: []const u8, condition: []const u8,