Skip to content

Commit 5d058bf

Browse files
authored
output: format timestamps and durations in --human (#41)
## Description Formats timestamps and durations in `--human` mode so they're actually human-readable. **Before:** ``` ID Title Status Created 4621f8ba1a8f4811b32f669c37be53a2 HeyGen in 20 Seconds completed 1774712936 ``` **After:** ``` ID Title Status Created 4621f8ba1a8f4811b32f669c37be53a2 HeyGen in 20 Seconds completed 2026-03-28 15:48 ``` **Timestamp formatting:** - Detects unix timestamps by heuristic: integer-valued floats between 1.5e9 and 2.2e9 (covers 2017–2039) - Formats as UTC: `2006-01-02 15:04` - Won't false-positive on durations (small floats), IDs (strings), or counts (integers outside range) **Duration formatting:** - Detects by field name: any field containing "duration" (case-insensitive) - `18.9649` → `18s`, `62.5` → `1m 2s`, `3661.0` → `1h 1m 1s` **Implementation:** - New `formatCell(v, fieldName)` function wraps `formatValue` with context-aware formatting - Called from `renderTable` and `renderKeyValue` (both paths get formatting) - `formatValue` unchanged — JSON mode is unaffected Stacked on PR #40 (help text fixes). ## Testing 3 new tests: - `TestHumanFormatter_Data_TimestampFormatting` — unix epoch `1774712936` renders as `2026-03-28 15:48` - `TestHumanFormatter_Data_DurationFormatting` — float `18.9649` with field name "duration" renders as `18s` - `TestHumanFormatter_Data_RegularFloatUnaffected` — float `3.14` with field name "score" stays `3.14`
1 parent 95a2a10 commit 5d058bf

2 files changed

Lines changed: 84 additions & 4 deletions

File tree

internal/output/human_formatter.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sort"
99
"strconv"
1010
"strings"
11+
"time"
1112

1213
"github.com/charmbracelet/lipgloss"
1314
"github.com/heygen-com/heygen-cli/internal/command"
@@ -98,7 +99,7 @@ func (f *HumanFormatter) renderTable(rows []map[string]any, columns []command.Co
9899

99100
for _, row := range rows {
100101
for i, col := range columns {
101-
cell := formatValue(fieldValue(row, col.Field))
102+
cell := formatCell(fieldValue(row, col.Field), col.Field)
102103
if w := lipgloss.Width(cell); w > widths[i] {
103104
widths[i] = w
104105
}
@@ -111,7 +112,7 @@ func (f *HumanFormatter) renderTable(rows []map[string]any, columns []command.Co
111112
for _, row := range rows {
112113
values := make([]string, len(columns))
113114
for i, col := range columns {
114-
values[i] = formatValue(fieldValue(row, col.Field))
115+
values[i] = formatCell(fieldValue(row, col.Field), col.Field)
115116
}
116117
if _, err := fmt.Fprintln(f.out, renderTableLine(values, widths, true)); err != nil {
117118
return err
@@ -159,7 +160,7 @@ func (f *HumanFormatter) renderKeyValue(obj map[string]any) error {
159160
for _, key := range keys {
160161
label := humanizeKey(key)
161162
padding := strings.Repeat(" ", max(0, maxLabelWidth-lipgloss.Width(label)))
162-
if _, err := fmt.Fprintf(f.out, "%s:%s %s\n", label, padding, formatValue(obj[key])); err != nil {
163+
if _, err := fmt.Fprintf(f.out, "%s:%s %s\n", label, padding, formatCell(obj[key], key)); err != nil {
163164
return err
164165
}
165166
}
@@ -232,6 +233,18 @@ func fieldValue(row map[string]any, path string) any {
232233
return current
233234
}
234235

236+
func formatCell(v any, fieldName string) string {
237+
if value, ok := v.(float64); ok {
238+
if isUnixTimestamp(value) {
239+
return time.Unix(int64(value), 0).UTC().Format("2006-01-02 15:04")
240+
}
241+
if strings.Contains(strings.ToLower(fieldName), "duration") {
242+
return formatDuration(value)
243+
}
244+
}
245+
return formatValue(v)
246+
}
247+
235248
func formatValue(v any) string {
236249
switch value := v.(type) {
237250
case nil:
@@ -261,6 +274,24 @@ func formatValue(v any) string {
261274
}
262275
}
263276

277+
func isUnixTimestamp(value float64) bool {
278+
if value != float64(int64(value)) {
279+
return false
280+
}
281+
return value > 1.5e9 && value < 2.2e9
282+
}
283+
284+
func formatDuration(seconds float64) string {
285+
d := time.Duration(seconds * float64(time.Second))
286+
if d < time.Minute {
287+
return fmt.Sprintf("%ds", int(d.Seconds()))
288+
}
289+
if d < time.Hour {
290+
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
291+
}
292+
return fmt.Sprintf("%dh %dm %ds", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60)
293+
}
294+
264295
func humanizeKey(key string) string {
265296
parts := strings.FieldsFunc(key, func(r rune) bool {
266297
return r == '_' || r == '-' || r == '.'
@@ -273,4 +304,3 @@ func humanizeKey(key string) string {
273304
}
274305
return strings.Join(parts, " ")
275306
}
276-

internal/output/human_formatter_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,56 @@ func TestHumanFormatter_Data_KeyValue(t *testing.T) {
7979
}
8080
}
8181

82+
func TestHumanFormatter_Data_TimestampFormatting(t *testing.T) {
83+
var out bytes.Buffer
84+
f := NewHumanFormatter(&out, &bytes.Buffer{})
85+
86+
input := json.RawMessage(`{"data":[{"id":"vid_123","created_at":1774712936}]}`)
87+
columns := []command.Column{
88+
{Header: "ID", Field: "id"},
89+
{Header: "Created", Field: "created_at"},
90+
}
91+
92+
if err := f.Data(input, "data", columns); err != nil {
93+
t.Fatalf("unexpected error: %v", err)
94+
}
95+
96+
got := stripANSI(out.String())
97+
if !strings.Contains(got, "2026-03-28 15:48") {
98+
t.Fatalf("timestamp was not formatted as UTC date/time:\n%s", got)
99+
}
100+
}
101+
102+
func TestHumanFormatter_Data_DurationFormatting(t *testing.T) {
103+
var out bytes.Buffer
104+
f := NewHumanFormatter(&out, &bytes.Buffer{})
105+
106+
input := json.RawMessage(`{"data":{"duration":18.9649,"status":"completed"}}`)
107+
if err := f.Data(input, "data", nil); err != nil {
108+
t.Fatalf("unexpected error: %v", err)
109+
}
110+
111+
got := stripANSI(out.String())
112+
if !strings.Contains(got, "Duration: 18s") {
113+
t.Fatalf("duration was not formatted as seconds:\n%s", got)
114+
}
115+
}
116+
117+
func TestHumanFormatter_Data_RegularFloatUnaffected(t *testing.T) {
118+
var out bytes.Buffer
119+
f := NewHumanFormatter(&out, &bytes.Buffer{})
120+
121+
input := json.RawMessage(`{"data":{"score":3.14}}`)
122+
if err := f.Data(input, "data", nil); err != nil {
123+
t.Fatalf("unexpected error: %v", err)
124+
}
125+
126+
got := stripANSI(out.String())
127+
if !strings.Contains(got, "Score: 3.14") {
128+
t.Fatalf("regular float should remain unchanged:\n%s", got)
129+
}
130+
}
131+
82132
func TestHumanFormatter_Data_PrimitiveArray(t *testing.T) {
83133
var out bytes.Buffer
84134
f := NewHumanFormatter(&out, &bytes.Buffer{})

0 commit comments

Comments
 (0)