//! ISO-8601 parser for usage feed reset timestamps.
//!
//! Handles full ISO-8601 strings, e.g.:
//! - 2026-09-16T08:00:00.037593+00:00
//! - 2026-09-15T23:46:00Z
//! - 2026-06-09T11:28:39.481128Z
//! - 2026-01-01T00:00:00+05:30
//! - 2026-01-01T00:00:00-08:00
//!
//! Returns epoch milliseconds (UTC), matching Date.parse in TypeScript.
const std = @import("std");
fn isLeapYear(year: i64) bool {
if (@mod(year, 4) != 0) return false;
if (@mod(year, 100) != 0) return true;
return @mod(year, 400) == 0;
}
const days_before_month_common = [_]i64{ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 };
const days_before_month_leap = [_]i64{ 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 };
pub fn daysFromCivil(year: i64, month: u8, day: u8) i64 {
// Days from 1970-01-01 to start of `year`
var leaps: i64 = 0;
if (year >= 1970) {
const y_prev = year - 1;
leaps = @divFloor(y_prev, 4) - @divFloor(y_prev, 100) + @divFloor(y_prev, 400) -
(@divFloor(1969, 4) - @divFloor(1969, 100) + @divFloor(1969, 400));
} else {
const y_curr = year;
leaps = @divFloor(1969, 4) - @divFloor(1969, 100) + @divFloor(1969, 400) -
(@divFloor(y_curr - 1, 4) - @divFloor(y_curr - 1, 100) + @divFloor(y_curr - 1, 400));
leaps = -leaps;
}
const days_in_years = (year - 1970) * 365 + leaps;
const m_days = if (isLeapYear(year)) days_before_month_leap[month - 1] else days_before_month_common[month - 1];
return days_in_years + m_days + (day - 1);
}
pub fn parseIso8601(str: []const u8) ?i64 {
const trimmed = std.mem.trim(u8, str, " \t\r\n");
if (trimmed.len < 19) return null; // YYYY-MM-DDTHH:MM:SS
if (trimmed[4] != '-' or trimmed[7] != '-' or (trimmed[10] != 'T' and trimmed[10] != 't' and trimmed[10] != ' ')) return null;
if (trimmed[13] != ':' or trimmed[16] != ':') return null;
const year = std.fmt.parseInt(i64, trimmed[0..4], 10) catch return null;
const month = std.fmt.parseInt(u8, trimmed[5..7], 10) catch return null;
const day = std.fmt.parseInt(u8, trimmed[8..10], 10) catch return null;
if (month < 1 or month > 12) return null;
if (day < 1 or day > 31) return null;
const hour = std.fmt.parseInt(i64, trimmed[11..13], 10) catch return null;
const minute = std.fmt.parseInt(i64, trimmed[14..16], 10) catch return null;
const second = std.fmt.parseInt(i64, trimmed[17..19], 10) catch return null;
if (hour < 0 or hour > 23 or minute < 0 or minute > 59 or second < 0 or second > 60) return null;
var idx: usize = 19;
var millis: i64 = 0;
if (idx < trimmed.len and trimmed[idx] == '.') {
idx += 1;
var frac_digits: usize = 0;
var frac_val: i64 = 0;
while (idx < trimmed.len and std.ascii.isDigit(trimmed[idx])) : (idx += 1) {
if (frac_digits < 3) {
frac_val = frac_val * 10 + (trimmed[idx] - '0');
frac_digits += 1;
}
}
while (frac_digits < 3) : (frac_digits += 1) {
frac_val *= 10;
}
millis = frac_val;
}
var offset_sec: i64 = 0;
if (idx < trimmed.len) {
if (trimmed[idx] == 'Z' or trimmed[idx] == 'z') {
idx += 1;
} else if (trimmed[idx] == '+' or trimmed[idx] == '-') {
const sign: i64 = if (trimmed[idx] == '+') 1 else -1;
idx += 1;
if (idx + 2 <= trimmed.len) {
const off_h = std.fmt.parseInt(i64, trimmed[idx .. idx + 2], 10) catch return null;
idx += 2;
var off_m: i64 = 0;
if (idx < trimmed.len and trimmed[idx] == ':') idx += 1;
if (idx + 2 <= trimmed.len) {
off_m = std.fmt.parseInt(i64, trimmed[idx .. idx + 2], 10) catch return null;
idx += 2;
}
offset_sec = sign * (off_h * 3600 + off_m * 60);
} else {
return null;
}
} else {
return null;
}
}
if (idx != trimmed.len) return null; // trailing unparsed garbage
const days = daysFromCivil(year, month, day);
const total_sec = days * 86400 + hour * 3600 + minute * 60 + second - offset_sec;
return total_sec * 1000 + millis;
}
test "parseIso8601 with various formats" {
const cases = [_]struct { s: []const u8, expected: i64 }{
.{ .s = "2026-09-16T08:00:00.037593+00:00", .expected = 1789545600037 },
.{ .s = "2026-09-15T23:46:00Z", .expected = 1789515960000 },
.{ .s = "2026-06-09T11:28:39.481128Z", .expected = 1781004519481 },
.{ .s = "2026-01-01T00:00:00+05:30", .expected = 1767205800000 },
.{ .s = "2026-01-01T00:00:00-08:00", .expected = 1767254400000 },
.{ .s = "2026-01-01T00:00:00.5Z", .expected = 1767225600500 },
.{ .s = "2026-01-01T00:00:00.12Z", .expected = 1767225600120 },
.{ .s = "2026-01-01T00:00:00.123Z", .expected = 1767225600123 },
.{ .s = "2026-01-01T00:00:00.1234Z", .expected = 1767225600123 },
};
for (cases) |c| {
const got = parseIso8601(c.s);
try std.testing.expectEqual(c.expected, got.?);
}
try std.testing.expect(parseIso8601("") == null);
try std.testing.expect(parseIso8601("not-a-date") == null);
try std.testing.expect(parseIso8601("2026-09-16") == null);
}