feat(quando-ljj): implement Duration type and Diff calculation
- Add Duration type with private start/end time fields
- Implement Diff(a, b) package function
- Implement integer methods (Seconds, Minutes, Hours, Days, Weeks, Months, Years)
- Implement float methods (MonthsFloat, YearsFloat) for precise calculations
- Months calculation handles month-end dates and leap years correctly
- Negative duration support (when a < b)
- Correct handling of year boundaries and leap years
- Comprehensive table-driven tests covering edge cases
- Negative duration tests
- Cross-boundary tests (year, leap year)
- Float precision tests
- Performance benchmarks (all meet targets)
- 98.6% test coverage (exceeds 95% requirement)
- Godoc comments with precision explanation
Benchmark results:
- Seconds: ~15ns (< 1µs target) ✓
- Months: ~54ns (< 1µs target) ✓
- MonthsFloat: ~172ns (< 2µs target) ✓
- Zero allocations for all operations
All acceptance criteria met:
✓ Duration type defined
✓ Diff(a, b) returns Duration
✓ All integer methods implemented (Seconds through Years)
✓ Float methods for Months and Years implemented
✓ Negative differences handled correctly
✓ Calculations correct across year boundaries
✓ Leap year handling correct
✓ Unit tests with 98.6% coverage
✓ Table-driven tests for various date ranges
✓ Benchmarks meet targets
✓ Godoc comments with precision explanation
2026-02-11 17:38:22 +01:00
|
|
|
package quando
|
|
|
|
|
|
2026-02-11 19:42:10 +01:00
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
feat(quando-ljj): implement Duration type and Diff calculation
- Add Duration type with private start/end time fields
- Implement Diff(a, b) package function
- Implement integer methods (Seconds, Minutes, Hours, Days, Weeks, Months, Years)
- Implement float methods (MonthsFloat, YearsFloat) for precise calculations
- Months calculation handles month-end dates and leap years correctly
- Negative duration support (when a < b)
- Correct handling of year boundaries and leap years
- Comprehensive table-driven tests covering edge cases
- Negative duration tests
- Cross-boundary tests (year, leap year)
- Float precision tests
- Performance benchmarks (all meet targets)
- 98.6% test coverage (exceeds 95% requirement)
- Godoc comments with precision explanation
Benchmark results:
- Seconds: ~15ns (< 1µs target) ✓
- Months: ~54ns (< 1µs target) ✓
- MonthsFloat: ~172ns (< 2µs target) ✓
- Zero allocations for all operations
All acceptance criteria met:
✓ Duration type defined
✓ Diff(a, b) returns Duration
✓ All integer methods implemented (Seconds through Years)
✓ Float methods for Months and Years implemented
✓ Negative differences handled correctly
✓ Calculations correct across year boundaries
✓ Leap year handling correct
✓ Unit tests with 98.6% coverage
✓ Table-driven tests for various date ranges
✓ Benchmarks meet targets
✓ Godoc comments with precision explanation
2026-02-11 17:38:22 +01:00
|
|
|
|
|
|
|
|
// Duration represents the difference between two dates.
|
|
|
|
|
// It provides methods to extract the duration in various units.
|
|
|
|
|
//
|
|
|
|
|
// Integer methods (Seconds, Minutes, Hours, Days, Weeks, Months, Years)
|
|
|
|
|
// return rounded-down values. For more precise calculations involving
|
|
|
|
|
// months and years, use MonthsFloat() and YearsFloat().
|
|
|
|
|
//
|
|
|
|
|
// Durations can be negative if the start date is after the end date.
|
|
|
|
|
type Duration struct {
|
|
|
|
|
start time.Time
|
|
|
|
|
end time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Diff calculates the duration between two dates.
|
|
|
|
|
// If a is before b, the duration is positive. If a is after b, the duration is negative.
|
|
|
|
|
//
|
|
|
|
|
// Example:
|
|
|
|
|
//
|
|
|
|
|
// start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
// end := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
// duration := quando.Diff(start, end)
|
|
|
|
|
// months := duration.Months() // 11 months
|
|
|
|
|
func Diff(a, b time.Time) Duration {
|
|
|
|
|
return Duration{
|
|
|
|
|
start: a,
|
|
|
|
|
end: b,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Seconds returns the number of seconds in the duration (rounded down).
|
|
|
|
|
func (d Duration) Seconds() int64 {
|
|
|
|
|
return int64(d.end.Sub(d.start).Seconds())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minutes returns the number of minutes in the duration (rounded down).
|
|
|
|
|
func (d Duration) Minutes() int64 {
|
|
|
|
|
return int64(d.end.Sub(d.start).Minutes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hours returns the number of hours in the duration (rounded down).
|
|
|
|
|
func (d Duration) Hours() int64 {
|
|
|
|
|
return int64(d.end.Sub(d.start).Hours())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Days returns the number of days in the duration (rounded down).
|
|
|
|
|
// This calculates calendar days, not 24-hour periods.
|
|
|
|
|
func (d Duration) Days() int {
|
|
|
|
|
start := d.start
|
|
|
|
|
end := d.end
|
|
|
|
|
negative := false
|
|
|
|
|
|
|
|
|
|
// Handle negative duration
|
|
|
|
|
if end.Before(start) {
|
|
|
|
|
start, end = end, start
|
|
|
|
|
negative = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate days by counting calendar days
|
|
|
|
|
days := 0
|
|
|
|
|
for start.Before(end) {
|
|
|
|
|
start = start.AddDate(0, 0, 1)
|
|
|
|
|
days++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if negative {
|
|
|
|
|
return -days
|
|
|
|
|
}
|
|
|
|
|
return days
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Weeks returns the number of weeks in the duration (rounded down).
|
|
|
|
|
func (d Duration) Weeks() int {
|
|
|
|
|
return d.Days() / 7
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Months returns the number of months in the duration (rounded down).
|
|
|
|
|
// This handles month-end dates and leap years correctly.
|
|
|
|
|
func (d Duration) Months() int {
|
|
|
|
|
start := d.start
|
|
|
|
|
end := d.end
|
|
|
|
|
negative := false
|
|
|
|
|
|
|
|
|
|
// Handle negative duration
|
|
|
|
|
if end.Before(start) {
|
|
|
|
|
start, end = end, start
|
|
|
|
|
negative = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate months based on year and month difference
|
|
|
|
|
years := end.Year() - start.Year()
|
|
|
|
|
months := int(end.Month()) - int(start.Month())
|
|
|
|
|
totalMonths := years*12 + months
|
|
|
|
|
|
|
|
|
|
// Adjust if end day is before start day (not a full month yet)
|
|
|
|
|
if end.Day() < start.Day() {
|
|
|
|
|
totalMonths--
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if negative {
|
|
|
|
|
return -totalMonths
|
|
|
|
|
}
|
|
|
|
|
return totalMonths
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Years returns the number of years in the duration (rounded down).
|
|
|
|
|
func (d Duration) Years() int {
|
|
|
|
|
return d.Months() / 12
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MonthsFloat returns the precise number of months in the duration as a float64.
|
|
|
|
|
// This provides more accurate calculations than the integer Months() method.
|
|
|
|
|
func (d Duration) MonthsFloat() float64 {
|
|
|
|
|
start := d.start
|
|
|
|
|
end := d.end
|
|
|
|
|
negative := false
|
|
|
|
|
|
|
|
|
|
// Handle negative duration
|
|
|
|
|
if end.Before(start) {
|
|
|
|
|
start, end = end, start
|
|
|
|
|
negative = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get integer months first
|
|
|
|
|
fullMonths := float64(d.Months())
|
|
|
|
|
if negative {
|
|
|
|
|
fullMonths = -fullMonths
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate fractional month based on days
|
|
|
|
|
// Create a date at the same day in the month after start + full months
|
|
|
|
|
baseDate := start.AddDate(0, int(fullMonths), 0)
|
|
|
|
|
|
|
|
|
|
// Days remaining after full months
|
|
|
|
|
daysRemaining := end.Sub(baseDate).Hours() / 24
|
|
|
|
|
|
|
|
|
|
// Days in the month we're currently in
|
|
|
|
|
nextMonth := baseDate.AddDate(0, 1, 0)
|
|
|
|
|
daysInMonth := nextMonth.Sub(baseDate).Hours() / 24
|
|
|
|
|
|
|
|
|
|
fractionalMonth := daysRemaining / daysInMonth
|
|
|
|
|
|
|
|
|
|
result := fullMonths + fractionalMonth
|
|
|
|
|
if negative {
|
|
|
|
|
return -result
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// YearsFloat returns the precise number of years in the duration as a float64.
|
|
|
|
|
// This provides more accurate calculations than the integer Years() method.
|
|
|
|
|
func (d Duration) YearsFloat() float64 {
|
|
|
|
|
return d.MonthsFloat() / 12.0
|
|
|
|
|
}
|
2026-02-11 19:42:10 +01:00
|
|
|
|
|
|
|
|
// Human returns a human-readable representation of the duration.
|
|
|
|
|
// It shows the two largest relevant time units for adaptive granularity.
|
|
|
|
|
//
|
|
|
|
|
// If no language is specified, English (EN) is used by default.
|
|
|
|
|
//
|
|
|
|
|
// Examples:
|
|
|
|
|
//
|
|
|
|
|
// dur := quando.Diff(start, end)
|
|
|
|
|
// dur.Human() // "10 months, 16 days" (English default)
|
|
|
|
|
// dur.Human(quando.DE) // "10 Monate, 16 Tage" (German)
|
|
|
|
|
//
|
|
|
|
|
// Adaptive granularity examples:
|
|
|
|
|
// - 10 months, 16 days → "10 months, 16 days"
|
|
|
|
|
// - 2 days, 5 hours → "2 days, 5 hours"
|
|
|
|
|
// - 3 hours, 20 minutes → "3 hours, 20 minutes"
|
|
|
|
|
// - 45 seconds → "45 seconds"
|
|
|
|
|
// - 0 → "0 seconds"
|
|
|
|
|
func (d Duration) Human(lang ...Lang) string {
|
|
|
|
|
// Default to English if no language specified
|
|
|
|
|
l := EN
|
|
|
|
|
if len(lang) > 0 {
|
|
|
|
|
l = lang[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle negative durations
|
|
|
|
|
negative := d.start.After(d.end)
|
|
|
|
|
|
|
|
|
|
// Calculate all time components (using absolute values)
|
|
|
|
|
totalSeconds := d.Seconds()
|
|
|
|
|
if totalSeconds < 0 {
|
|
|
|
|
totalSeconds = -totalSeconds
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate months and years
|
|
|
|
|
months := d.Months()
|
|
|
|
|
if months < 0 {
|
|
|
|
|
months = -months
|
|
|
|
|
}
|
|
|
|
|
years := months / 12
|
|
|
|
|
remainingMonths := months % 12
|
|
|
|
|
|
|
|
|
|
// Calculate remaining components after extracting larger units
|
|
|
|
|
// After years and months, calculate remaining days
|
|
|
|
|
// We need to subtract the time represented by years and months from total
|
|
|
|
|
|
|
|
|
|
// Start from the beginning and add years + months
|
|
|
|
|
baseTime := d.start
|
|
|
|
|
if negative {
|
|
|
|
|
baseTime = d.end
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterYearsMonths := baseTime.AddDate(years, remainingMonths, 0)
|
|
|
|
|
|
|
|
|
|
// Calculate remaining time
|
|
|
|
|
var remainingEnd time.Time
|
|
|
|
|
if negative {
|
|
|
|
|
remainingEnd = d.start
|
|
|
|
|
} else {
|
|
|
|
|
remainingEnd = d.end
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
remainingDuration := remainingEnd.Sub(afterYearsMonths)
|
|
|
|
|
remainingDays := int(remainingDuration.Hours() / 24)
|
|
|
|
|
remainingHours := int(remainingDuration.Hours()) % 24
|
|
|
|
|
remainingMinutes := int(remainingDuration.Minutes()) % 60
|
|
|
|
|
remainingSeconds := int(remainingDuration.Seconds()) % 60
|
|
|
|
|
|
|
|
|
|
// Build component list with values
|
|
|
|
|
type component struct {
|
|
|
|
|
value int
|
|
|
|
|
unit string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
components := []component{
|
|
|
|
|
{years, "year"},
|
|
|
|
|
{remainingMonths, "month"},
|
|
|
|
|
{remainingDays, "day"},
|
|
|
|
|
{remainingHours, "hour"},
|
|
|
|
|
{remainingMinutes, "minute"},
|
|
|
|
|
{remainingSeconds, "second"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter to non-zero components
|
|
|
|
|
var nonZero []component
|
|
|
|
|
for _, c := range components {
|
|
|
|
|
if c.value > 0 {
|
|
|
|
|
nonZero = append(nonZero, c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle zero duration special case
|
|
|
|
|
if len(nonZero) == 0 {
|
|
|
|
|
return "0 " + l.DurationUnit("second", true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Take up to 2 largest units for adaptive granularity
|
|
|
|
|
displayUnits := nonZero
|
|
|
|
|
if len(displayUnits) > 2 {
|
|
|
|
|
displayUnits = displayUnits[:2]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build the output string
|
|
|
|
|
var parts []string
|
|
|
|
|
for _, c := range displayUnits {
|
|
|
|
|
unitName := l.DurationUnit(c.unit, c.value != 1)
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%d %s", c.value, unitName))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := ""
|
|
|
|
|
if len(parts) == 1 {
|
|
|
|
|
result = parts[0]
|
|
|
|
|
} else if len(parts) == 2 {
|
|
|
|
|
result = parts[0] + ", " + parts[1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add negative prefix if needed
|
|
|
|
|
if negative {
|
|
|
|
|
// Use minus sign for simplicity (could be localized in future)
|
|
|
|
|
result = "-" + result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|