- 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
120 lines
3.4 KiB
Go
120 lines
3.4 KiB
Go
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())
|
|
}
|