feat(i18n): add localized time formatting helpers
- Add TimeAgo(t time.Time) for relative time strings
- Add FormatAgo(count, unit) for "N units ago" composition
- Add i18n.ago namespace pattern: T("i18n.ago", 5, "minute")
- Uses existing time.ago.{unit} keys with CLDR pluralization
- Remove local formatTimeAgo from cmd/php in favor of i18n.TimeAgo
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d1b1809cb
commit
282be9c7bc
4 changed files with 149 additions and 27 deletions
|
|
@ -338,7 +338,7 @@ func printDeploymentSummary(index int, status *phppkg.DeploymentStatus) {
|
|||
|
||||
age := ""
|
||||
if !status.StartedAt.IsZero() {
|
||||
age = formatTimeAgo(status.StartedAt)
|
||||
age = i18n.TimeAgo(status.StartedAt)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s %s",
|
||||
|
|
@ -362,29 +362,3 @@ func printDeploymentSummary(index int, status *phppkg.DeploymentStatus) {
|
|||
fmt.Println()
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return i18n.T("cli.time.just_now")
|
||||
case duration < time.Hour:
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return i18n.T("cli.time.minute_ago")
|
||||
}
|
||||
return i18n.T("cli.time.minutes_ago", map[string]interface{}{"Count": mins})
|
||||
case duration < 24*time.Hour:
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return i18n.T("cli.time.hour_ago")
|
||||
}
|
||||
return i18n.T("cli.time.hours_ago", map[string]interface{}{"Count": hours})
|
||||
default:
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return i18n.T("cli.time.day_ago")
|
||||
}
|
||||
return i18n.T("cli.time.days_ago", map[string]interface{}{"Count": days})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -750,6 +750,14 @@ func (s *Service) handleI18nNamespace(key string, args []any) string {
|
|||
return FormatOrdinal(toInt(args[0]))
|
||||
}
|
||||
|
||||
// i18n.ago → FormatAgo(count, unit)
|
||||
if key == "i18n.ago" && len(args) >= 2 {
|
||||
count := toInt(args[0])
|
||||
if unit, ok := args[1].(string); ok {
|
||||
return FormatAgo(count, unit)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
55
pkg/i18n/time.go
Normal file
55
pkg/i18n/time.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeAgo returns a localized relative time string.
|
||||
//
|
||||
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
|
||||
// TimeAgo(time.Now().Add(-1 * time.Hour)) // "1 hour ago"
|
||||
func TimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return T("time.just_now")
|
||||
case duration < time.Hour:
|
||||
mins := int(duration.Minutes())
|
||||
return FormatAgo(mins, "minute")
|
||||
case duration < 24*time.Hour:
|
||||
hours := int(duration.Hours())
|
||||
return FormatAgo(hours, "hour")
|
||||
case duration < 7*24*time.Hour:
|
||||
days := int(duration.Hours() / 24)
|
||||
return FormatAgo(days, "day")
|
||||
default:
|
||||
weeks := int(duration.Hours() / (24 * 7))
|
||||
return FormatAgo(weeks, "week")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatAgo formats "N unit ago" with proper pluralization.
|
||||
// Uses locale-specific patterns from time.ago.{unit}.
|
||||
//
|
||||
// FormatAgo(5, "minute") // "5 minutes ago"
|
||||
// FormatAgo(1, "hour") // "1 hour ago"
|
||||
func FormatAgo(count int, unit string) string {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return fmt.Sprintf("%d %ss ago", count, unit)
|
||||
}
|
||||
|
||||
// Try locale-specific pattern: time.ago.{unit}
|
||||
key := "time.ago." + unit
|
||||
result := svc.T(key, map[string]any{"Count": count})
|
||||
|
||||
// If key was returned as-is (not found), compose fallback
|
||||
if result == key {
|
||||
return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
85
pkg/i18n/time_test.go
Normal file
85
pkg/i18n/time_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFormatAgo(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
unit string
|
||||
expected string
|
||||
}{
|
||||
{"1 second", 1, "second", "1 second ago"},
|
||||
{"5 seconds", 5, "second", "5 seconds ago"},
|
||||
{"1 minute", 1, "minute", "1 minute ago"},
|
||||
{"30 minutes", 30, "minute", "30 minutes ago"},
|
||||
{"1 hour", 1, "hour", "1 hour ago"},
|
||||
{"3 hours", 3, "hour", "3 hours ago"},
|
||||
{"1 day", 1, "day", "1 day ago"},
|
||||
{"7 days", 7, "day", "7 days ago"},
|
||||
{"1 week", 1, "week", "1 week ago"},
|
||||
{"2 weeks", 2, "week", "2 weeks ago"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := FormatAgo(tt.count, tt.unit)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAgo(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ago time.Duration
|
||||
expected string
|
||||
}{
|
||||
{"just now", 30 * time.Second, "just now"},
|
||||
{"1 minute", 1 * time.Minute, "1 minute ago"},
|
||||
{"5 minutes", 5 * time.Minute, "5 minutes ago"},
|
||||
{"1 hour", 1 * time.Hour, "1 hour ago"},
|
||||
{"3 hours", 3 * time.Hour, "3 hours ago"},
|
||||
{"1 day", 24 * time.Hour, "1 day ago"},
|
||||
{"3 days", 3 * 24 * time.Hour, "3 days ago"},
|
||||
{"1 week", 7 * 24 * time.Hour, "1 week ago"},
|
||||
{"2 weeks", 14 * 24 * time.Hour, "2 weeks ago"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TimeAgo(time.Now().Add(-tt.ago))
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestI18nAgoNamespace(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
t.Run("i18n.ago pattern", func(t *testing.T) {
|
||||
result := T("i18n.ago", 5, "minute")
|
||||
assert.Equal(t, "5 minutes ago", result)
|
||||
})
|
||||
|
||||
t.Run("i18n.ago singular", func(t *testing.T) {
|
||||
result := T("i18n.ago", 1, "hour")
|
||||
assert.Equal(t, "1 hour ago", result)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue