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 := ""
|
age := ""
|
||||||
if !status.StartedAt.IsZero() {
|
if !status.StartedAt.IsZero() {
|
||||||
age = formatTimeAgo(status.StartedAt)
|
age = i18n.TimeAgo(status.StartedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %s %s %s",
|
fmt.Printf(" %s %s %s",
|
||||||
|
|
@ -362,29 +362,3 @@ func printDeploymentSummary(index int, status *phppkg.DeploymentStatus) {
|
||||||
fmt.Println()
|
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]))
|
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 ""
|
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