zfin/src/models/date.zig
2026-03-01 11:22:37 -08:00

286 lines
11 KiB
Zig

/// Date represented as days since epoch (compact, sortable).
/// Use helper functions for formatting and parsing.
pub const Date = struct {
/// Days since 1970-01-01
days: i32,
pub const epoch = Date{ .days = 0 };
pub fn year(self: Date) i16 {
return epochDaysToYmd(self.days).year;
}
pub fn month(self: Date) u8 {
return epochDaysToYmd(self.days).month;
}
pub fn day(self: Date) u8 {
return epochDaysToYmd(self.days).day;
}
pub fn fromYmd(y: i16, m: u8, d: u8) Date {
return .{ .days = ymdToEpochDays(y, m, d) };
}
/// Parse "YYYY-MM-DD" format
pub fn parse(str: []const u8) !Date {
if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat;
const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat;
const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat;
const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat;
return fromYmd(y, m, d);
}
/// Format as "YYYY-MM-DD"
pub fn format(self: Date, buf: *[10]u8) []const u8 {
const ymd = epochDaysToYmd(self.days);
const y: u16 = @intCast(ymd.year);
buf[0] = '0' + @as(u8, @intCast(y / 1000));
buf[1] = '0' + @as(u8, @intCast((y / 100) % 10));
buf[2] = '0' + @as(u8, @intCast((y / 10) % 10));
buf[3] = '0' + @as(u8, @intCast(y % 10));
buf[4] = '-';
buf[5] = '0' + @as(u8, @intCast(ymd.month / 10));
buf[6] = '0' + @as(u8, @intCast(ymd.month % 10));
buf[7] = '-';
buf[8] = '0' + @as(u8, @intCast(ymd.day / 10));
buf[9] = '0' + @as(u8, @intCast(ymd.day % 10));
return buf[0..10];
}
/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday.
pub fn dayOfWeek(self: Date) u8 {
// 1970-01-01 was a Thursday (day 3 in 0=Mon scheme)
const d = @mod(self.days + 3, @as(i32, 7));
return @intCast(if (d < 0) d + 7 else d);
}
pub fn eql(a: Date, b: Date) bool {
return a.days == b.days;
}
pub fn lessThan(a: Date, b: Date) bool {
return a.days < b.days;
}
pub fn addDays(self: Date, n: i32) Date {
return .{ .days = self.days + n };
}
/// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year.
pub fn subtractYears(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
const new_year: i16 = ymd.year - @as(i16, @intCast(n));
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
/// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28).
pub fn subtractMonths(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
var m: i32 = @as(i32, ymd.month) - @as(i32, n);
var y: i16 = ymd.year;
while (m < 1) {
m += 12;
y -= 1;
}
const new_month: u8 = @intCast(m);
const max_day = daysInMonth(y, new_month);
const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day;
return .{ .days = ymdToEpochDays(y, new_month, new_day) };
}
/// Return the last day of the previous month.
/// E.g., if self is 2026-02-24, returns 2026-01-31.
pub fn lastDayOfPriorMonth(self: Date) Date {
const ymd = epochDaysToYmd(self.days);
if (ymd.month == 1) {
return fromYmd(ymd.year - 1, 12, 31);
} else {
return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1));
}
}
fn daysInMonth(y: i16, m: u8) u8 {
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (m == 2 and isLeapYear(y)) return 29;
return table[m - 1];
}
/// Returns approximate number of years between two dates
pub fn yearsBetween(from: Date, to: Date) f64 {
return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
}
fn isLeapYear(y: i16) bool {
const yu: u16 = @bitCast(y);
return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0);
}
};
const Ymd = struct { year: i16, month: u8, day: u8 };
fn epochDaysToYmd(days: i32) Ymd {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
// Using i64 throughout to avoid overflow on unsigned intermediate values.
const z: i64 = @as(i64, days) + 719468;
const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097);
const doe_i: i64 = z - era * 146097; // [0, 146096]
const doe: u64 = @intCast(doe_i);
const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400;
const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100);
const mp: u64 = (5 * doy + 2) / 153;
const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1);
const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9;
const m: u8 = @intCast(m_raw);
const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y);
return .{ .year = y_adj, .month = m, .day = d };
}
fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0));
const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400);
const yoe: u64 = @intCast(y_adj - era * 400);
const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9;
const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1;
const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy;
return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468);
}
const std = @import("std");
test "date roundtrip" {
const d = Date.fromYmd(2024, 6, 15);
try std.testing.expectEqual(@as(i16, 2024), d.year());
try std.testing.expectEqual(@as(u8, 6), d.month());
try std.testing.expectEqual(@as(u8, 15), d.day());
}
test "date parse" {
const d = try Date.parse("2024-06-15");
try std.testing.expectEqual(@as(i16, 2024), d.year());
try std.testing.expectEqual(@as(u8, 6), d.month());
try std.testing.expectEqual(@as(u8, 15), d.day());
}
test "date format" {
const d = Date.fromYmd(2024, 1, 5);
var buf: [10]u8 = undefined;
const s = d.format(&buf);
try std.testing.expectEqualStrings("2024-01-05", s);
}
test "subtractYears" {
const d = Date.fromYmd(2026, 2, 24);
const d1 = d.subtractYears(1);
try std.testing.expectEqual(@as(i16, 2025), d1.year());
try std.testing.expectEqual(@as(u8, 2), d1.month());
try std.testing.expectEqual(@as(u8, 24), d1.day());
const d3 = d.subtractYears(3);
try std.testing.expectEqual(@as(i16, 2023), d3.year());
// Leap year edge case: Feb 29 2024 - 1 year = Feb 28 2023
const leap = Date.fromYmd(2024, 2, 29);
const non_leap = leap.subtractYears(1);
try std.testing.expectEqual(@as(i16, 2023), non_leap.year());
try std.testing.expectEqual(@as(u8, 2), non_leap.month());
try std.testing.expectEqual(@as(u8, 28), non_leap.day());
}
test "lastDayOfPriorMonth" {
// Feb 24 -> Jan 31
const d1 = Date.fromYmd(2026, 2, 24).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(i16, 2026), d1.year());
try std.testing.expectEqual(@as(u8, 1), d1.month());
try std.testing.expectEqual(@as(u8, 31), d1.day());
// Jan 15 -> Dec 31 of prior year
const d2 = Date.fromYmd(2026, 1, 15).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(i16, 2025), d2.year());
try std.testing.expectEqual(@as(u8, 12), d2.month());
try std.testing.expectEqual(@as(u8, 31), d2.day());
// Mar 1 leap year -> Feb 29
const d3 = Date.fromYmd(2024, 3, 1).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(u8, 2), d3.month());
try std.testing.expectEqual(@as(u8, 29), d3.day());
// Mar 1 non-leap -> Feb 28
const d4 = Date.fromYmd(2025, 3, 1).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(u8, 2), d4.month());
try std.testing.expectEqual(@as(u8, 28), d4.day());
}
test "dayOfWeek" {
// 1970-01-01 was a Thursday (3 in 0=Mon scheme)
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
// 2024-01-01 (Monday)
try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek());
// 2024-01-19 (Friday)
try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek());
// 2024-01-20 (Saturday)
try std.testing.expectEqual(@as(u8, 5), Date.fromYmd(2024, 1, 20).dayOfWeek());
// 2024-01-21 (Sunday)
try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek());
}
test "eql and lessThan" {
const a = Date.fromYmd(2024, 6, 15);
const b = Date.fromYmd(2024, 6, 15);
const c = Date.fromYmd(2024, 6, 16);
try std.testing.expect(a.eql(b));
try std.testing.expect(!a.eql(c));
try std.testing.expect(a.lessThan(c));
try std.testing.expect(!c.lessThan(a));
try std.testing.expect(!a.lessThan(b));
}
test "addDays" {
const d = Date.fromYmd(2024, 1, 30);
const next = d.addDays(2);
try std.testing.expectEqual(@as(u8, 1), next.day());
try std.testing.expectEqual(@as(u8, 2), next.month()); // Feb 1
// Negative days
const prev = d.addDays(-30);
try std.testing.expectEqual(@as(u8, 31), prev.day());
try std.testing.expectEqual(@as(u8, 12), prev.month()); // Dec 31
try std.testing.expectEqual(@as(i16, 2023), prev.year());
}
test "subtractMonths" {
// Normal case
const d1 = Date.fromYmd(2024, 6, 15).subtractMonths(3);
try std.testing.expectEqual(@as(u8, 3), d1.month());
try std.testing.expectEqual(@as(u8, 15), d1.day());
// Cross year boundary: Jan - 1 = Dec prior year
const d2 = Date.fromYmd(2024, 1, 15).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 12), d2.month());
try std.testing.expectEqual(@as(i16, 2023), d2.year());
// Day clamping: Mar 31 - 1M = Feb 29 (leap year 2024)
const d3 = Date.fromYmd(2024, 3, 31).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 2), d3.month());
try std.testing.expectEqual(@as(u8, 29), d3.day());
// Day clamping: Mar 31 - 1M = Feb 28 (non-leap year 2025)
const d4 = Date.fromYmd(2025, 3, 31).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 2), d4.month());
try std.testing.expectEqual(@as(u8, 28), d4.day());
}
test "yearsBetween" {
const a = Date.fromYmd(2024, 1, 1);
const b = Date.fromYmd(2025, 1, 1);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), Date.yearsBetween(a, b), 0.01);
// Half year
const c = Date.fromYmd(2024, 7, 1);
try std.testing.expectApproxEqAbs(@as(f64, 0.5), Date.yearsBetween(a, c), 0.02);
// Same date
try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001);
}
test "parse error cases" {
try std.testing.expectError(error.InvalidDateFormat, Date.parse("not-a-date"));
try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes
try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator
}