feat(quando-10t): implement human-readable duration format with i18n support

Add Duration.Human() method for localized, human-readable duration formatting:
- Adaptive granularity: displays 2 largest time units
- i18n support for EN and DE languages
- Handles singular/plural forms automatically
- Supports negative durations with minus prefix
- Zero duration special case handling

Performance: ~0.88µs/op (11x faster than 10µs target)
Coverage: 100% on diff.go, 98.2% overall

Files modified:
- diff.go: Added Human() method with fmt import
- diff_test.go: Added 18 comprehensive tests + 2 benchmarks
- example_test.go: Added 3 example functions
This commit is contained in:
Oliver Jakoubek 2026-02-11 19:42:10 +01:00
commit 999ac9a7a3
4 changed files with 368 additions and 2 deletions

View file

@ -1,4 +1,4 @@
{"id":"quando-10t","title":"Human-readable duration format with i18n","description":"Implement Human() method on Duration for human-readable output with internationalization.\n\n**API:**\n```go\nfunc (dur Duration) Human() string\nfunc (dur Duration) Human(lang Lang) string\n```\n\n**Adaptive Granularity:**\nAlways show the two largest relevant units:\n\n| Difference | EN Output | DE Output |\n|------------|-----------|-----------|\n| 10 months, 16 days | \"10 months, 16 days\" | \"10 Monate, 16 Tage\" |\n| 2 days, 5 hours | \"2 days, 5 hours\" | \"2 Tage, 5 Stunden\" |\n| 3 hours, 20 minutes | \"3 hours, 20 minutes\" | \"3 Stunden, 20 Minuten\" |\n| 45 seconds | \"45 seconds\" | \"45 Sekunden\" |\n| 0 | \"0 seconds\" | \"0 Sekunden\" |\n\n**Language Support (Phase 1):**\n- EN (English) - default\n- DE (Deutsch) - must-have\n\n## Acceptance Criteria\n- [ ] Human() without argument returns English\n- [ ] Human(Lang) accepts language parameter\n- [ ] Adaptive granularity: two largest units\n- [ ] English translations complete\n- [ ] German translations complete\n- [ ] Zero duration handled (\"0 seconds\")\n- [ ] Singular/plural forms correct (1 day vs 2 days)\n- [ ] Unit tests for all granularity levels\n- [ ] Unit tests for both EN and DE\n- [ ] Benchmark meets \u003c10µs target with i18n\n- [ ] Godoc comments with examples in both languages","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:12.954367096+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:21:12.954367096+01:00","dependencies":[{"issue_id":"quando-10t","depends_on_id":"quando-ljj","type":"blocks","created_at":"2026-02-11T16:23:09.100489733+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-10t","depends_on_id":"quando-zbr","type":"blocks","created_at":"2026-02-11T16:23:09.139531731+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-10t","title":"Human-readable duration format with i18n","description":"Implement Human() method on Duration for human-readable output with internationalization.\n\n**API:**\n```go\nfunc (dur Duration) Human() string\nfunc (dur Duration) Human(lang Lang) string\n```\n\n**Adaptive Granularity:**\nAlways show the two largest relevant units:\n\n| Difference | EN Output | DE Output |\n|------------|-----------|-----------|\n| 10 months, 16 days | \"10 months, 16 days\" | \"10 Monate, 16 Tage\" |\n| 2 days, 5 hours | \"2 days, 5 hours\" | \"2 Tage, 5 Stunden\" |\n| 3 hours, 20 minutes | \"3 hours, 20 minutes\" | \"3 Stunden, 20 Minuten\" |\n| 45 seconds | \"45 seconds\" | \"45 Sekunden\" |\n| 0 | \"0 seconds\" | \"0 Sekunden\" |\n\n**Language Support (Phase 1):**\n- EN (English) - default\n- DE (Deutsch) - must-have\n\n## Acceptance Criteria\n- [ ] Human() without argument returns English\n- [ ] Human(Lang) accepts language parameter\n- [ ] Adaptive granularity: two largest units\n- [ ] English translations complete\n- [ ] German translations complete\n- [ ] Zero duration handled (\"0 seconds\")\n- [ ] Singular/plural forms correct (1 day vs 2 days)\n- [ ] Unit tests for all granularity levels\n- [ ] Unit tests for both EN and DE\n- [ ] Benchmark meets \u003c10µs target with i18n\n- [ ] Godoc comments with examples in both languages","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:12.954367096+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:42:05.783678823+01:00","closed_at":"2026-02-11T19:42:05.783678823+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-10t","depends_on_id":"quando-ljj","type":"blocks","created_at":"2026-02-11T16:23:09.100489733+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-10t","depends_on_id":"quando-zbr","type":"blocks","created_at":"2026-02-11T16:23:09.139531731+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"quando-36t","title":"Error types and handling","description":"Define custom error types and establish error handling patterns for the library.\n\n**Sentinel Errors:**\n```go\nvar (\n ErrInvalidFormat = errors.New(\"invalid date format\")\n ErrInvalidTimezone = errors.New(\"invalid timezone\")\n ErrOverflow = errors.New(\"date overflow\")\n)\n```\n\n**Error Handling Principles:**\n1. NEVER panic (except Must* variants)\n2. Use sentinel errors for known error types\n3. Wrap errors with fmt.Errorf(\"%w\") for context\n4. Return clear, actionable error messages\n\n**Error Categories:**\n- Parse errors: Invalid formats, ambiguous inputs\n- Timezone errors: Unknown IANA names\n- Overflow errors: Date arithmetic outside Go's time.Time range\n\n**Documentation:**\n- Document that library never panics in normal operation\n- Document that Must* variants DO panic\n- Provide error handling examples\n\n## Acceptance Criteria\n- [ ] errors.go file created\n- [ ] ErrInvalidFormat defined\n- [ ] ErrInvalidTimezone defined\n- [ ] ErrOverflow defined\n- [ ] Godoc for each error with usage context\n- [ ] Documentation of no-panic policy\n- [ ] Documentation of Must* panic behavior\n- [ ] Example tests showing error handling patterns","status":"closed","priority":1,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:22.314746489+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T17:40:06.634744398+01:00","closed_at":"2026-02-11T17:40:06.634744398+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-36t","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.380454674+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":8,"issue_id":"quando-36t","author":"Oliver Jakoubek","text":"Plan: 1) Create errors.go with sentinel errors (ErrInvalidFormat, ErrInvalidTimezone, ErrOverflow), 2) Add comprehensive godoc comments for each error with usage context, 3) Document no-panic policy in package documentation, 4) Document Must* variant panic behavior, 5) Add example tests showing error handling patterns (error checking, unwrapping)","created_at":"2026-02-11T16:38:47Z"}]} {"id":"quando-36t","title":"Error types and handling","description":"Define custom error types and establish error handling patterns for the library.\n\n**Sentinel Errors:**\n```go\nvar (\n ErrInvalidFormat = errors.New(\"invalid date format\")\n ErrInvalidTimezone = errors.New(\"invalid timezone\")\n ErrOverflow = errors.New(\"date overflow\")\n)\n```\n\n**Error Handling Principles:**\n1. NEVER panic (except Must* variants)\n2. Use sentinel errors for known error types\n3. Wrap errors with fmt.Errorf(\"%w\") for context\n4. Return clear, actionable error messages\n\n**Error Categories:**\n- Parse errors: Invalid formats, ambiguous inputs\n- Timezone errors: Unknown IANA names\n- Overflow errors: Date arithmetic outside Go's time.Time range\n\n**Documentation:**\n- Document that library never panics in normal operation\n- Document that Must* variants DO panic\n- Provide error handling examples\n\n## Acceptance Criteria\n- [ ] errors.go file created\n- [ ] ErrInvalidFormat defined\n- [ ] ErrInvalidTimezone defined\n- [ ] ErrOverflow defined\n- [ ] Godoc for each error with usage context\n- [ ] Documentation of no-panic policy\n- [ ] Documentation of Must* panic behavior\n- [ ] Example tests showing error handling patterns","status":"closed","priority":1,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:22.314746489+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T17:40:06.634744398+01:00","closed_at":"2026-02-11T17:40:06.634744398+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-36t","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.380454674+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":8,"issue_id":"quando-36t","author":"Oliver Jakoubek","text":"Plan: 1) Create errors.go with sentinel errors (ErrInvalidFormat, ErrInvalidTimezone, ErrOverflow), 2) Add comprehensive godoc comments for each error with usage context, 3) Document no-panic policy in package documentation, 4) Document Must* variant panic behavior, 5) Add example tests showing error handling patterns (error checking, unwrapping)","created_at":"2026-02-11T16:38:47Z"}]}
{"id":"quando-41g","title":"Timezone support and conversion","description":"Implement timezone conversion with proper DST handling.\n\n**API:**\n```go\nfunc (d Date) In(location string) (Date, error)\n```\n\n**Behavior:**\n- Convert date to specified IANA timezone\n- Return error for invalid timezone names (never panic)\n- Use IANA Timezone Database\n- Default timezone: UTC if not specified\n\n**DST Handling:**\nCritical: `Add(1, Days)` means \"same time next calendar day\", NOT 24 hours\n- Example: 2026-03-31 02:00 CET + 1 Day = 2026-04-01 02:00 CEST\n- This is only 23 actual hours due to DST transition\n- Rationale: Humans think in calendar days, not hour deltas\n\n**Error Handling:**\n- Validate IANA timezone names\n- Return clear error for unknown timezones\n- Return clear error for empty timezone string\n\n## Acceptance Criteria\n- [ ] In(location) implemented\n- [ ] Uses IANA Timezone Database\n- [ ] Converts to specified timezone correctly\n- [ ] Invalid timezone names return error\n- [ ] Empty string returns error\n- [ ] Never panics on invalid input\n- [ ] DST handling: Add(Days) preserves wall clock time\n- [ ] Tests across DST transitions (spring and fall)\n- [ ] Tests for multiple timezones (Europe/Berlin, America/New_York, etc.)\n- [ ] Unit tests with 95%+ coverage\n- [ ] Godoc with DST behavior clearly explained\n- [ ] Example showing DST-safe arithmetic","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:14.704688038+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:18:57.926412162+01:00","closed_at":"2026-02-11T19:18:57.926412162+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-41g","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:13.346573362+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-41g","depends_on_id":"quando-36t","type":"blocks","created_at":"2026-02-11T16:23:13.384247178+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-41g","title":"Timezone support and conversion","description":"Implement timezone conversion with proper DST handling.\n\n**API:**\n```go\nfunc (d Date) In(location string) (Date, error)\n```\n\n**Behavior:**\n- Convert date to specified IANA timezone\n- Return error for invalid timezone names (never panic)\n- Use IANA Timezone Database\n- Default timezone: UTC if not specified\n\n**DST Handling:**\nCritical: `Add(1, Days)` means \"same time next calendar day\", NOT 24 hours\n- Example: 2026-03-31 02:00 CET + 1 Day = 2026-04-01 02:00 CEST\n- This is only 23 actual hours due to DST transition\n- Rationale: Humans think in calendar days, not hour deltas\n\n**Error Handling:**\n- Validate IANA timezone names\n- Return clear error for unknown timezones\n- Return clear error for empty timezone string\n\n## Acceptance Criteria\n- [ ] In(location) implemented\n- [ ] Uses IANA Timezone Database\n- [ ] Converts to specified timezone correctly\n- [ ] Invalid timezone names return error\n- [ ] Empty string returns error\n- [ ] Never panics on invalid input\n- [ ] DST handling: Add(Days) preserves wall clock time\n- [ ] Tests across DST transitions (spring and fall)\n- [ ] Tests for multiple timezones (Europe/Berlin, America/New_York, etc.)\n- [ ] Unit tests with 95%+ coverage\n- [ ] Godoc with DST behavior clearly explained\n- [ ] Example showing DST-safe arithmetic","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:14.704688038+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:18:57.926412162+01:00","closed_at":"2026-02-11T19:18:57.926412162+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-41g","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:13.346573362+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-41g","depends_on_id":"quando-36t","type":"blocks","created_at":"2026-02-11T16:23:13.384247178+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"quando-4bh","title":"Unit type and constants","description":"Define Unit type for time unit constants used in arithmetic operations.\n\n**Technical Details:**\n```go\ntype Unit int\n\nconst (\n Seconds Unit = iota\n Minutes\n Hours\n Days\n Weeks\n Months\n Quarters\n Years\n)\n```\n\n**Design:**\n- Type-safe constants (compile-time safety)\n- Use iota for clear ordering\n- Optional internal ParseUnit(string) for external inputs\n\n## Acceptance Criteria\n- [ ] Unit type defined as int\n- [ ] All 8 unit constants defined (Seconds through Years)\n- [ ] Units use iota for ordering\n- [ ] Godoc comments for Unit type and constants\n- [ ] Unit tests verifying constants","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:20:37.246514285+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:34:46.001417204+01:00","closed_at":"2026-02-11T16:34:46.001417204+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-4bh","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.349519431+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":4,"issue_id":"quando-4bh","author":"Oliver Jakoubek","text":"Plan: 1) Create unit.go with Unit type as int, 2) Define all 8 unit constants (Seconds, Minutes, Hours, Days, Weeks, Months, Quarters, Years) using iota, 3) Add String() method for better debugging, 4) Add comprehensive godoc comments, 5) Write unit tests verifying constants and ordering, 6) Add example tests","created_at":"2026-02-11T15:33:41Z"}]} {"id":"quando-4bh","title":"Unit type and constants","description":"Define Unit type for time unit constants used in arithmetic operations.\n\n**Technical Details:**\n```go\ntype Unit int\n\nconst (\n Seconds Unit = iota\n Minutes\n Hours\n Days\n Weeks\n Months\n Quarters\n Years\n)\n```\n\n**Design:**\n- Type-safe constants (compile-time safety)\n- Use iota for clear ordering\n- Optional internal ParseUnit(string) for external inputs\n\n## Acceptance Criteria\n- [ ] Unit type defined as int\n- [ ] All 8 unit constants defined (Seconds through Years)\n- [ ] Units use iota for ordering\n- [ ] Godoc comments for Unit type and constants\n- [ ] Unit tests verifying constants","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:20:37.246514285+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:34:46.001417204+01:00","closed_at":"2026-02-11T16:34:46.001417204+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-4bh","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.349519431+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":4,"issue_id":"quando-4bh","author":"Oliver Jakoubek","text":"Plan: 1) Create unit.go with Unit type as int, 2) Define all 8 unit constants (Seconds, Minutes, Hours, Days, Weeks, Months, Quarters, Years) using iota, 3) Add String() method for better debugging, 4) Add comprehensive godoc comments, 5) Write unit tests verifying constants and ordering, 6) Add example tests","created_at":"2026-02-11T15:33:41Z"}]}

129
diff.go
View file

@ -1,6 +1,9 @@
package quando package quando
import "time" import (
"fmt"
"time"
)
// Duration represents the difference between two dates. // Duration represents the difference between two dates.
// It provides methods to extract the duration in various units. // It provides methods to extract the duration in various units.
@ -155,3 +158,127 @@ func (d Duration) MonthsFloat() float64 {
func (d Duration) YearsFloat() float64 { func (d Duration) YearsFloat() float64 {
return d.MonthsFloat() / 12.0 return d.MonthsFloat() / 12.0
} }
// 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
}

View file

@ -2,6 +2,7 @@ package quando
import ( import (
"math" "math"
"strings"
"testing" "testing"
"time" "time"
) )
@ -562,3 +563,196 @@ func TestFloatPrecision(t *testing.T) {
t.Error("MonthsFloat() should have fractional part") t.Error("MonthsFloat() should have fractional part")
} }
} }
func TestDurationHuman(t *testing.T) {
tests := []struct {
name string
start time.Time
end time.Time
lang Lang
expected string
}{
// English tests
{
name: "10 months 16 days EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 11, 17, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "10 months, 16 days",
},
{
name: "2 days 5 hours EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 3, 5, 0, 0, 0, time.UTC),
lang: EN,
expected: "2 days, 5 hours",
},
{
name: "3 hours 20 minutes EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 3, 20, 0, 0, time.UTC),
lang: EN,
expected: "3 hours, 20 minutes",
},
{
name: "45 seconds EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 45, 0, time.UTC),
lang: EN,
expected: "45 seconds",
},
{
name: "zero duration EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "0 seconds",
},
{
name: "1 year 2 months EN (singular/plural mix)",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "1 year, 2 months",
},
{
name: "1 day EN (singular)",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "1 day",
},
// German tests
{
name: "10 months 16 days DE",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 11, 17, 0, 0, 0, 0, time.UTC),
lang: DE,
expected: "10 Monate, 16 Tage",
},
{
name: "2 days 5 hours DE",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 3, 5, 0, 0, 0, time.UTC),
lang: DE,
expected: "2 Tage, 5 Stunden",
},
{
name: "3 hours 20 minutes DE",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 3, 20, 0, 0, time.UTC),
lang: DE,
expected: "3 Stunden, 20 Minuten",
},
{
name: "45 seconds DE",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 45, 0, time.UTC),
lang: DE,
expected: "45 Sekunden",
},
{
name: "zero duration DE",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
lang: DE,
expected: "0 Sekunden",
},
// Negative duration tests
{
name: "negative 2 days EN",
start: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "-2 days",
},
{
name: "negative 1 month DE",
start: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
lang: DE,
expected: "-1 Monat",
},
// Edge cases
{
name: "exactly 1 year EN",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
lang: EN,
expected: "1 year",
},
{
name: "1 minute 30 seconds EN (only shows 2 largest)",
start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2026, 1, 1, 0, 1, 30, 0, time.UTC),
lang: EN,
expected: "1 minute, 30 seconds",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dur := Diff(tt.start, tt.end)
result := dur.Human(tt.lang)
if result != tt.expected {
t.Errorf("Human(%v) = %q, want %q", tt.lang, result, tt.expected)
}
})
}
}
func TestDurationHumanDefaultLanguage(t *testing.T) {
// Test that Human() without argument defaults to English
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 3, 5, 0, 0, 0, time.UTC)
dur := Diff(start, end)
result := dur.Human()
expected := "2 days, 5 hours"
if result != expected {
t.Errorf("Human() = %q, want %q (should default to English)", result, expected)
}
}
func TestDurationHumanAdaptiveGranularity(t *testing.T) {
// Test that only the 2 largest units are shown
// 1 year, 2 months, 3 days should show "1 year, 2 months"
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2027, 3, 4, 0, 0, 0, 0, time.UTC)
dur := Diff(start, end)
result := dur.Human(EN)
// Should only show years and months, not days
if !strings.Contains(result, "year") || !strings.Contains(result, "month") {
t.Errorf("Human() = %q, should contain 'year' and 'month'", result)
}
if strings.Contains(result, "day") {
t.Errorf("Human() = %q, should NOT contain 'day' (only 2 largest units)", result)
}
}
func BenchmarkDurationHuman(b *testing.B) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 11, 17, 5, 30, 45, 0, time.UTC)
dur := Diff(start, end)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = dur.Human(EN)
}
}
func BenchmarkDurationHumanGerman(b *testing.B) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 11, 17, 5, 30, 45, 0, time.UTC)
dur := Diff(start, end)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = dur.Human(DE)
}
}

View file

@ -492,3 +492,48 @@ func ExampleLang_DurationUnit() {
// months // months
// Monate // Monate
} }
// ExampleDuration_Human demonstrates human-readable duration formatting in English
func ExampleDuration_Human() {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 11, 17, 0, 0, 0, 0, time.UTC)
dur := quando.Diff(start, end)
fmt.Println(dur.Human())
// Output: 10 months, 16 days
}
// ExampleDuration_Human_german demonstrates human-readable duration formatting in German
func ExampleDuration_Human_german() {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 3, 5, 0, 0, 0, time.UTC)
dur := quando.Diff(start, end)
fmt.Println(dur.Human(quando.DE))
// Output: 2 Tage, 5 Stunden
}
// ExampleDuration_Human_adaptive demonstrates adaptive granularity
func ExampleDuration_Human_adaptive() {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Large duration: shows years and months
end1 := time.Date(2027, 3, 15, 0, 0, 0, 0, time.UTC)
dur1 := quando.Diff(start, end1)
fmt.Println(dur1.Human())
// Medium duration: shows days and hours
end2 := time.Date(2026, 1, 3, 5, 0, 0, 0, time.UTC)
dur2 := quando.Diff(start, end2)
fmt.Println(dur2.Human())
// Small duration: shows seconds only
end3 := time.Date(2026, 1, 1, 0, 0, 45, 0, time.UTC)
dur3 := quando.Diff(start, end3)
fmt.Println(dur3.Human())
// Output:
// 1 year, 2 months
// 2 days, 5 hours
// 45 seconds
}