Skip to content

Commit 0d46fd2

Browse files
feat(timeutil): support configurable timezone for timestamp display (#346)
* feat(timeutil): support configurable timezone for timestamp display Add ENGRAM_TIMEZONE env var to control how timestamps are displayed across all output surfaces (mem_context, mem_search, mem_timeline, TUI, and CLI commands). Storage stays in UTC; only display is converted. - New internal/timeutil package with FormatLocal() helper - Respects ENGRAM_TIMEZONE (e.g. America/Bogota); falls back to system local - Applied to: tui/view.go, mcp.go, store.FormatContext, cmd/engram search+timeline Closes #169 * feat(dashboard): honor ENGRAM_TIMEZONE in cards and accept SQLite timestamps Two reports landed on #169 after #346 was opened: pepe-alehop confirmed the binary-level env approach is needed in Docker, and quirozino traced a separate bug in the dashboard helper where formatTimestamp parsed only RFC3339 and silently returned raw UTC for SQLite-style values ("YYYY-MM-DD HH:MM:SS"), leaking UTC into the UI for any value arriving in that shape. Extends the existing timeutil package with FormatLocalWithLayout so UI surfaces can pass their own layout while keeping ENGRAM_TIMEZONE conversion centralized. The dashboard helper now delegates to it and accepts both RFC3339 and SQLite-style inputs. FormatLocal becomes a thin wrapper over FormatLocalWithLayout with the default layout, so no caller changes outside the dashboard. Adds regression tests: - dashboard formatTimestamp resolves the SQLite-style #169 report - formatTimestamp honors ENGRAM_TIMEZONE (Bogota + Madrid cases) - empty / unparseable inputs preserve the documented contract - timeutil FormatLocalWithLayout round-trips the friendly dashboard layout --------- Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
1 parent 98f4e1d commit 0d46fd2

8 files changed

Lines changed: 221 additions & 23 deletions

File tree

cmd/engram/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/Gentleman-Programming/engram/internal/setup"
4040
"github.com/Gentleman-Programming/engram/internal/store"
4141
engramsync "github.com/Gentleman-Programming/engram/internal/sync"
42+
"github.com/Gentleman-Programming/engram/internal/timeutil"
4243
"github.com/Gentleman-Programming/engram/internal/tui"
4344
versioncheck "github.com/Gentleman-Programming/engram/internal/version"
4445

@@ -975,7 +976,7 @@ func cmdSearch(cfg store.Config) {
975976
fmt.Printf("[%d] #%d (%s) — %s\n %s\n %s%s | scope: %s\n\n",
976977
i+1, r.ID, r.Type, r.Title,
977978
truncate(r.Content, 300),
978-
r.CreatedAt, project, r.Scope)
979+
timeutil.FormatLocal(r.CreatedAt), project, r.Scope)
979980
}
980981
}
981982

@@ -1115,7 +1116,7 @@ func cmdTimeline(cfg store.Config) {
11151116
// Focus
11161117
fmt.Printf(">>> #%d [%s] %s <<<\n", result.Focus.ID, result.Focus.Type, result.Focus.Title)
11171118
fmt.Printf(" %s\n", truncate(result.Focus.Content, 500))
1118-
fmt.Printf(" %s\n\n", result.Focus.CreatedAt)
1119+
fmt.Printf(" %s\n\n", timeutil.FormatLocal(result.Focus.CreatedAt))
11191120

11201121
// After
11211122
if len(result.After) > 0 {

internal/cloud/dashboard/helpers.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"unicode/utf8"
1414

1515
"github.com/Gentleman-Programming/engram/internal/cloud/cloudstore"
16+
"github.com/Gentleman-Programming/engram/internal/timeutil"
1617
)
1718

1819
// ─── Pagination ─────────────────────────────────────────────────────────────
@@ -238,19 +239,27 @@ func typePillClass(activeType, candidate string) string {
238239
return "type-pill"
239240
}
240241

242+
// dashboardTimestampLayout is the human-friendly layout shown on dashboard cards
243+
// (e.g. "22 May 2026 11:31"). Storage stays UTC; only the rendered string changes.
244+
const dashboardTimestampLayout = "02 Jan 2006 15:04"
245+
246+
// formatTimestamp renders a stored UTC timestamp for dashboard display. It accepts
247+
// both RFC3339 and SQLite-style values ("YYYY-MM-DD HH:MM:SS") and honors
248+
// ENGRAM_TIMEZONE for the display zone, falling back to system local time.
249+
//
250+
// Returns "-" for an empty string. If the value cannot be parsed in any supported
251+
// layout, the original string is returned unchanged so we never lose data on
252+
// malformed timestamps.
241253
func formatTimestamp(ts string) string {
242254
ts = strings.TrimSpace(ts)
243255
if ts == "" {
244256
return "-"
245257
}
246-
parsed, err := time.Parse(time.RFC3339Nano, ts)
247-
if err != nil {
248-
return ts
249-
}
250-
return parsed.Local().Format("02 Jan 2006 15:04")
258+
return timeutil.FormatLocalWithLayout(ts, dashboardTimestampLayout)
251259
}
252260

253261
// ADAPTED: legacy had formatTimestampPtr(*string); integrated uses string fields.
262+
// Cards that show "last seen" or similar nullable values render "Never" instead of "-".
254263
func formatTimestampStr(ts string) string {
255264
if ts == "" {
256265
return "Never"

internal/cloud/dashboard/helpers_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dashboard
22

33
import (
4+
"os"
45
"strings"
56
"testing"
67
)
@@ -78,3 +79,79 @@ func TestSanitizeDashboardNextNormalizesAndConstrainsPath(t *testing.T) {
7879
})
7980
}
8081
}
82+
83+
// withEngramTimezone forces ENGRAM_TIMEZONE for the test body and restores
84+
// the original value afterwards, so timezone-sensitive tests stay isolated.
85+
func withEngramTimezone(t *testing.T, tz string) {
86+
t.Helper()
87+
old, hadOld := os.LookupEnv("ENGRAM_TIMEZONE")
88+
t.Setenv("ENGRAM_TIMEZONE", tz)
89+
t.Cleanup(func() {
90+
if hadOld {
91+
os.Setenv("ENGRAM_TIMEZONE", old)
92+
return
93+
}
94+
os.Unsetenv("ENGRAM_TIMEZONE")
95+
})
96+
}
97+
98+
// TestFormatTimestampParsesSQLiteStyleInput is the regression test for the bug
99+
// reported on #169 by @quirozino. The legacy dashboard helper called
100+
// time.Parse(time.RFC3339Nano, ts) which silently failed on SQLite-style
101+
// "YYYY-MM-DD HH:MM:SS" values and leaked raw UTC strings into the UI. The new
102+
// helper delegates to timeutil and must convert these values correctly.
103+
func TestFormatTimestampParsesSQLiteStyleInput(t *testing.T) {
104+
withEngramTimezone(t, "America/Bogota") // UTC-5
105+
106+
// SQLite-style timestamp stored as UTC noon → 07:00 in Bogota.
107+
got := formatTimestamp("2026-05-22 12:00:00")
108+
want := "22 May 2026 07:00"
109+
if got != want {
110+
t.Fatalf("formatTimestamp(SQLite-style) = %q, want %q (regression of #169 dashboard bug)", got, want)
111+
}
112+
}
113+
114+
func TestFormatTimestampParsesRFC3339Input(t *testing.T) {
115+
withEngramTimezone(t, "America/Bogota")
116+
117+
got := formatTimestamp("2026-05-22T12:00:00Z")
118+
want := "22 May 2026 07:00"
119+
if got != want {
120+
t.Fatalf("formatTimestamp(RFC3339) = %q, want %q", got, want)
121+
}
122+
}
123+
124+
func TestFormatTimestampHonorsEngramTimezone(t *testing.T) {
125+
withEngramTimezone(t, "Europe/Madrid") // UTC+2 in May (CEST)
126+
127+
got := formatTimestamp("2026-05-22T09:56:00Z")
128+
want := "22 May 2026 11:56"
129+
if got != want {
130+
t.Fatalf("formatTimestamp under Europe/Madrid = %q, want %q", got, want)
131+
}
132+
}
133+
134+
func TestFormatTimestampEmptyReturnsDash(t *testing.T) {
135+
if got := formatTimestamp(""); got != "-" {
136+
t.Fatalf("formatTimestamp(empty) = %q, want %q", got, "-")
137+
}
138+
if got := formatTimestamp(" "); got != "-" {
139+
t.Fatalf("formatTimestamp(whitespace) = %q, want %q", got, "-")
140+
}
141+
}
142+
143+
func TestFormatTimestampUnparseableReturnsRaw(t *testing.T) {
144+
// Malformed input must not be lost; the helper returns it as-is so the UI
145+
// stays informative even when the source data is unexpected.
146+
in := "not-a-timestamp"
147+
if got := formatTimestamp(in); got != in {
148+
t.Fatalf("formatTimestamp(unparseable) = %q, want %q", got, in)
149+
}
150+
}
151+
152+
func TestFormatTimestampStrEmptyReturnsNever(t *testing.T) {
153+
if got := formatTimestampStr(""); got != "Never" {
154+
t.Fatalf("formatTimestampStr(empty) = %q, want %q", got, "Never")
155+
}
156+
}
157+

internal/mcp/mcp.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/Gentleman-Programming/engram/internal/diagnostic"
2727
projectpkg "github.com/Gentleman-Programming/engram/internal/project"
2828
"github.com/Gentleman-Programming/engram/internal/store"
29+
"github.com/Gentleman-Programming/engram/internal/timeutil"
2930
"github.com/mark3labs/mcp-go/mcp"
3031
"github.com/mark3labs/mcp-go/server"
3132
)
@@ -981,7 +982,7 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv
981982
fmt.Fprintf(&b, "[%d] #%d (%s) — %s\n %s\n %s%s | scope: %s\n",
982983
i+1, r.ID, r.Type, r.Title,
983984
preview,
984-
r.CreatedAt, projectDisplay, r.Scope)
985+
timeutil.FormatLocal(r.CreatedAt), projectDisplay, r.Scope)
985986

986987
// Append relation annotations. Skip orphaned (filtered by store).
987988
//
@@ -1546,7 +1547,7 @@ func handleTimeline(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
15461547
// Focus observation (highlighted)
15471548
fmt.Fprintf(&b, ">>> #%d [%s] %s <<<\n", result.Focus.ID, result.Focus.Type, result.Focus.Title)
15481549
fmt.Fprintf(&b, " %s\n", truncate(result.Focus.Content, 500))
1549-
fmt.Fprintf(&b, " %s\n\n", result.Focus.CreatedAt)
1550+
fmt.Fprintf(&b, " %s\n\n", timeutil.FormatLocal(result.Focus.CreatedAt))
15501551

15511552
// After entries
15521553
if len(result.After) > 0 {
@@ -1597,7 +1598,7 @@ func handleGetObservation(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc
15971598
obs.ID, obs.Type, obs.Title,
15981599
obs.Content,
15991600
obs.SessionID, obsProject+scope+topic, toolName+duplicateMeta+revisionMeta,
1600-
obs.CreatedAt,
1601+
timeutil.FormatLocal(obs.CreatedAt),
16011602
)
16021603

16031604
if detErr != nil {

internal/store/store.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"strings"
2222
"time"
2323

24+
"github.com/Gentleman-Programming/engram/internal/timeutil"
2425
sqlite "modernc.org/sqlite"
2526
)
2627

@@ -3082,15 +3083,15 @@ func (s *Store) FormatContext(project, scope string) (string, error) {
30823083
summary = fmt.Sprintf(": %s", truncate(*sess.Summary, 200))
30833084
}
30843085
fmt.Fprintf(&b, "- **%s** (%s)%s [%d observations]\n",
3085-
sess.Project, sess.StartedAt, summary, sess.ObservationCount)
3086+
sess.Project, timeutil.FormatLocal(sess.StartedAt), summary, sess.ObservationCount)
30863087
}
30873088
b.WriteString("\n")
30883089
}
30893090

30903091
if len(prompts) > 0 {
30913092
b.WriteString("### Recent User Prompts\n")
30923093
for _, p := range prompts {
3093-
fmt.Fprintf(&b, "- %s: %s\n", p.CreatedAt, truncate(p.Content, 200))
3094+
fmt.Fprintf(&b, "- %s: %s\n", timeutil.FormatLocal(p.CreatedAt), truncate(p.Content, 200))
30943095
}
30953096
b.WriteString("\n")
30963097
}

internal/timeutil/timeutil.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package timeutil
2+
3+
import (
4+
"os"
5+
"time"
6+
)
7+
8+
// defaultLayout is used by FormatLocal when callers do not care about display style.
9+
const defaultLayout = "2006-01-02 15:04:05"
10+
11+
// FormatLocal converts a UTC timestamp string to the configured display timezone using
12+
// the default layout ("2006-01-02 15:04:05"). When ENGRAM_TIMEZONE is unset or invalid
13+
// it falls back to system local time. Unparseable input is returned as-is.
14+
//
15+
// Accepted input layouts: "2006-01-02 15:04:05" (SQLite style), time.RFC3339, time.RFC3339Nano.
16+
func FormatLocal(utc string) string {
17+
return FormatLocalWithLayout(utc, defaultLayout)
18+
}
19+
20+
// FormatLocalWithLayout is the layout-aware variant of FormatLocal. It applies the same
21+
// timezone conversion rules and accepts the same input formats, but renders the output
22+
// with the caller's layout. Use this when a UI surface (e.g. the dashboard) needs a
23+
// specific display style while still honoring ENGRAM_TIMEZONE.
24+
//
25+
// If the input cannot be parsed in any of the accepted layouts, the original string is
26+
// returned unchanged so callers never lose data on malformed timestamps.
27+
func FormatLocalWithLayout(utc, layout string) string {
28+
for _, in := range []string{
29+
"2006-01-02 15:04:05",
30+
time.RFC3339,
31+
time.RFC3339Nano,
32+
} {
33+
if t, err := time.Parse(in, utc); err == nil {
34+
return toConfiguredLocal(t.UTC()).Format(layout)
35+
}
36+
}
37+
return utc
38+
}
39+
40+
func toConfiguredLocal(t time.Time) time.Time {
41+
tz := os.Getenv("ENGRAM_TIMEZONE")
42+
if tz != "" {
43+
if loc, err := time.LoadLocation(tz); err == nil {
44+
return t.In(loc)
45+
}
46+
}
47+
// Fallback to system local
48+
return t.Local()
49+
}

internal/timeutil/timeutil_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package timeutil
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestFormatLocal(t *testing.T) {
9+
// Original tz
10+
oldTz := os.Getenv("ENGRAM_TIMEZONE")
11+
defer os.Setenv("ENGRAM_TIMEZONE", oldTz)
12+
13+
// Set to Bogota
14+
os.Setenv("ENGRAM_TIMEZONE", "America/Bogota")
15+
16+
// UTC time string
17+
utcStr := "2026-05-04 12:00:00" // noon UTC
18+
// In Bogota (UTC-5), this should be 07:00:00
19+
expected := "2026-05-04 07:00:00"
20+
21+
result := FormatLocal(utcStr)
22+
if result != expected {
23+
t.Errorf("Expected %s but got %s", expected, result)
24+
}
25+
26+
// Test RFC3339
27+
rfcStr := "2026-05-04T12:00:00Z"
28+
resultRfc := FormatLocal(rfcStr)
29+
if resultRfc != expected {
30+
t.Errorf("Expected RFC %s but got %s", expected, resultRfc)
31+
}
32+
33+
// Test invalid
34+
invalidStr := "not-a-time"
35+
if FormatLocal(invalidStr) != invalidStr {
36+
t.Errorf("Expected invalid string to be returned as-is")
37+
}
38+
39+
// Test fallback when env var is empty
40+
os.Setenv("ENGRAM_TIMEZONE", "")
41+
// When empty, it falls back to system local. It's hard to assert exactly without mocking time.Local,
42+
// but we can just ensure it parses successfully and doesn't panic.
43+
if FormatLocal(utcStr) == "" {
44+
t.Errorf("Expected fallback to format something, got empty string")
45+
}
46+
}
47+
48+
// TestFormatLocalWithLayoutPreservesCallerLayout covers the dashboard use case:
49+
// UI surfaces pass a friendly layout (e.g. "02 Jan 2006 15:04") and still get
50+
// ENGRAM_TIMEZONE-aware conversion. The dashboard regression test in
51+
// internal/cloud/dashboard/helpers_test.go relies on this contract.
52+
func TestFormatLocalWithLayoutPreservesCallerLayout(t *testing.T) {
53+
old := os.Getenv("ENGRAM_TIMEZONE")
54+
defer os.Setenv("ENGRAM_TIMEZONE", old)
55+
os.Setenv("ENGRAM_TIMEZONE", "America/Bogota") // UTC-5
56+
57+
// SQLite-style input → friendly dashboard layout output, in Bogota time.
58+
got := FormatLocalWithLayout("2026-05-22 12:00:00", "02 Jan 2006 15:04")
59+
want := "22 May 2026 07:00"
60+
if got != want {
61+
t.Errorf("FormatLocalWithLayout = %q, want %q", got, want)
62+
}
63+
64+
// Unparseable input still round-trips unchanged, regardless of layout.
65+
raw := "not-a-time"
66+
if FormatLocalWithLayout(raw, "02 Jan 2006 15:04") != raw {
67+
t.Errorf("expected unparseable input to be returned as-is")
68+
}
69+
}

internal/tui/view.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package tui
33
import (
44
"fmt"
55
"strings"
6-
"time"
76

7+
"github.com/Gentleman-Programming/engram/internal/timeutil"
88
"github.com/Gentleman-Programming/engram/internal/version"
99
"github.com/charmbracelet/lipgloss"
1010
)
@@ -713,16 +713,7 @@ func (m Model) renderObservationListItem(index int, id int64, obsType, title, co
713713

714714
// localTime converts a UTC timestamp string from SQLite to local time for display.
715715
func localTime(utc string) string {
716-
for _, layout := range []string{
717-
"2006-01-02 15:04:05",
718-
time.RFC3339,
719-
time.RFC3339Nano,
720-
} {
721-
if t, err := time.Parse(layout, utc); err == nil {
722-
return t.UTC().Local().Format("2006-01-02 15:04:05")
723-
}
724-
}
725-
return utc // unparseable — return as-is
716+
return timeutil.FormatLocal(utc)
726717
}
727718

728719
func truncateStr(s string, max int) string {

0 commit comments

Comments
 (0)