quando/arithmetic.go

120 lines
3.4 KiB
Go
Raw Permalink Normal View History

feat(quando-b4r): implement Add and Sub arithmetic operations - Add Add(value, unit) method for all 8 units - Add Sub(value, unit) method (wraps Add with negative value) - Implement month-end overflow logic for Months/Quarters/Years - When adding months, if target day doesn't exist, snap to last day of month - Handle all month-end combinations correctly - Leap year support (Feb 29 edge cases) - DST-safe calendar day arithmetic for Days unit - Negative value support (Add(-1) == Sub(1)) - Method chaining support (fluent API) - Immutability verified (original date never modified) - Timezone preservation across operations - Comprehensive table-driven tests for month-end edge cases - All 30/31 day month combinations tested - Leap year tests (Feb 29 -> Feb 28) - Cross-year boundary tests - Negative value tests - Method chaining tests - Performance benchmarks (all < 1µs) - 98.8% test coverage (exceeds 95% requirement) - Godoc with month-end overflow examples Benchmark results: - AddDays: ~42ns (< 1µs target) ✓ - AddMonths: ~191ns (< 1µs target) ✓ - AddYears: ~202ns (< 1µs target) ✓ - Method chaining: ~263ns for 3 ops ✓ - Zero allocations for all operations All acceptance criteria met: ✓ Add() implemented for all 8 units ✓ Sub() implemented for all 8 units ✓ Month-end overflow logic correct ✓ Leap year handling (Feb 29 edge cases) ✓ DST handling (calendar days) ✓ Negative values supported ✓ Method chaining works ✓ Unit tests with 98.8% coverage ✓ Table-driven tests for month-end edge cases ✓ Benchmarks meet <1µs target ✓ Godoc comments with month-end examples
2026-02-11 17:43:03 +01:00
package quando
import "time"
// Add adds the specified number of units to the date and returns a new Date.
// The original date is not modified (immutability).
//
// Supported units: Seconds, Minutes, Hours, Days, Weeks, Months, Quarters, Years
//
// Month-End Overflow Behavior:
//
// When adding months (or quarters/years which add months internally), if the target
// day doesn't exist in the destination month, the date is snapped to the last day
// of that month instead of overflowing into the next month.
//
// Examples:
// - 2026-01-31 + 1 month = 2026-02-28 (February has only 28 days in 2026)
// - 2026-01-24 + 1 month = 2026-02-24 (regular addition, day exists)
// - 2026-05-31 + 1 month = 2026-06-30 (June has only 30 days)
// - 2024-01-31 + 1 month = 2024-02-29 (leap year, February has 29 days)
//
// DST Handling:
//
// Adding Days means "same time on the next calendar day", not "24 hours later".
// This ensures that operations work intuitively across DST transitions.
//
// Example:
// - 2026-03-31 02:00 CET + 1 Day = 2026-04-01 02:00 CEST (not 03:00)
//
// Negative Values:
//
// Negative values are supported and equivalent to subtraction:
// - Add(-1, Months) is the same as Sub(1, Months)
//
// Example:
//
// date := quando.From(time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC))
// result := date.Add(1, quando.Months) // 2026-02-28 12:00:00
func (d Date) Add(value int, unit Unit) Date {
t := d.t
switch unit {
case Seconds:
t = t.Add(time.Duration(value) * time.Second)
case Minutes:
t = t.Add(time.Duration(value) * time.Minute)
case Hours:
t = t.Add(time.Duration(value) * time.Hour)
case Days:
// Use AddDate for calendar days (DST-safe)
t = t.AddDate(0, 0, value)
case Weeks:
// 1 week = 7 days
t = t.AddDate(0, 0, value*7)
case Months:
// Add months with month-end overflow handling
t = addMonthsWithOverflow(t, value)
case Quarters:
// 1 quarter = 3 months
t = addMonthsWithOverflow(t, value*3)
case Years:
// 1 year = 12 months
t = addMonthsWithOverflow(t, value*12)
}
return Date{t: t, lang: d.lang}
}
// Sub subtracts the specified number of units from the date and returns a new Date.
// This is equivalent to Add with a negative value.
//
// Example:
//
// date := quando.From(time.Date(2026, 3, 31, 12, 0, 0, 0, time.UTC))
// result := date.Sub(1, quando.Months) // 2026-02-28 12:00:00 (month-end snap)
func (d Date) Sub(value int, unit Unit) Date {
return d.Add(-value, unit)
}
// addMonthsWithOverflow adds months to a time.Time with month-end overflow handling.
// If the target day doesn't exist in the destination month, it snaps to the last day.
func addMonthsWithOverflow(t time.Time, months int) time.Time {
// Calculate target year and month
year := t.Year()
month := int(t.Month()) + months
day := t.Day()
// Handle year overflow/underflow
for month > 12 {
year++
month -= 12
}
for month < 1 {
year--
month += 12
}
targetMonth := time.Month(month)
// Get the last day of the target month
// Strategy: First day of next month minus 1 day
firstOfNextMonth := time.Date(year, targetMonth+1, 1, 0, 0, 0, 0, t.Location())
lastDayOfTargetMonth := firstOfNextMonth.AddDate(0, 0, -1).Day()
// If target day exceeds last day of month, snap to last day
if day > lastDayOfTargetMonth {
day = lastDayOfTargetMonth
}
// Construct the result date with the same time of day
return time.Date(year, targetMonth, day,
t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
}