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
This commit is contained in:
parent
2bf1df03ea
commit
7c7cb1a4d9
4 changed files with 602 additions and 2 deletions
120
arithmetic.go
Normal file
120
arithmetic.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue