Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions cmd/hookwise/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1434,19 +1434,24 @@ func TestDoctorFeedHealthStale(t *testing.T) {
os.MkdirAll(cacheDir, 0o700)

// Feed config lives in the GLOBAL config now — the singleton daemon sources
// feeds canonically from there (#89), and doctor reports against that. Weather
// interval = 60s.
// feeds canonically from there (#89), and doctor reports against that.
// Both intervals = 60s, so the stale threshold (2x interval) is 120s.
globalYAML := `version: 1
feeds:
weather:
enabled: true
interval_seconds: 60
calendar:
enabled: true
interval_seconds: 60
`
os.WriteFile(filepath.Join(stateDir, "config.yaml"), []byte(globalYAML), 0o644)
// Minimal project config so Check 1 passes without a project feeds: block.
os.WriteFile(filepath.Join(tmpDir, core.ProjectConfigFile), []byte("version: 1\n"), 0o644)

// Write a stale weather feed (timestamp 5 minutes ago, interval is 60s, threshold is 120s).
// Write stale feeds (timestamp 5 minutes ago, past the 120s threshold).
// Calendar carries real events so it exercises the staleness WARN, not the
// empty-data or zero-liveness paths that run first.
staleTime := time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339)
writeJSONFile(t, filepath.Join(cacheDir, "weather.json"), map[string]interface{}{
"type": "weather",
Expand All @@ -1456,11 +1461,31 @@ feeds:
"emoji": "☀️",
},
})
writeJSONFile(t, filepath.Join(cacheDir, "calendar.json"), map[string]interface{}{
"type": "calendar",
"timestamp": staleTime,
"data": map[string]interface{}{
"events": []interface{}{
map[string]interface{}{
"name": "Standup",
"start": "2026-03-07T10:30:00Z",
"end": "2026-03-07T11:00:00Z",
"all_day": false,
"is_current": false,
},
},
"next_event": map[string]interface{}{
"name": "Standup",
"start": "2026-03-07T10:30:00Z",
},
},
})

output, err := executeCommand("doctor")
require.NoError(t, err, "doctor failed\noutput: %s", output)

assert.Contains(t, output, "WARN feed:weather: stale data", "doctor should warn about stale weather feed")
assert.Contains(t, output, "WARN feed:calendar: stale data", "doctor should warn about stale calendar feed")
}

// A feed whose cache is fresh but whose data payload is empty ({}) is a real
Expand Down
64 changes: 64 additions & 0 deletions cmd/hookwise/cmd_status_line_calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,67 @@ func TestCalendarRelativeTime(t *testing.T) {
})
}
}

// TestCalendarRelativeTime_NonUTCOffsets pins label behavior when now and the
// event carry different UTC offsets (hw-8d9k). Google Calendar delivers event
// times in the calendar's own timezone (e.g. +05:30), while render-time "now"
// is the machine's local clock, so mixed-offset inputs are the normal case.
// Durations ("in 30m") are instant-based and must ignore offsets entirely;
// the day labels ("tomorrow 9:00am", "Fri 2:30pm") show the EVENT's own wall
// clock while the tomorrow/weekday split is computed from each side's own
// calendar day — these cases document that current behavior.
func TestCalendarRelativeTime_NonUTCOffsets(t *testing.T) {
ist := time.FixedZone("UTC+05:30", 5*3600+30*60)
pst := time.FixedZone("UTC-08:00", -8*3600)

tests := []struct {
name string
now time.Time
event time.Time
want string
}{
{
name: "offset event minutes away from UTC now",
now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC),
event: time.Date(2026, 6, 15, 16, 0, 0, 0, ist), // 10:30 UTC
want: "in 30m",
},
{
name: "same instant across zones is now",
now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC),
event: time.Date(2026, 6, 15, 15, 30, 0, 0, ist), // 10:00 UTC
want: "now",
},
{
name: "non-UTC now vs offset event counts real hours",
now: time.Date(2026, 6, 15, 2, 0, 0, 0, pst), // 10:00 UTC
event: time.Date(2026, 6, 15, 19, 0, 0, 0, ist), // 13:30 UTC
want: "in 3h 30m",
},
{
name: "countdown crossing midnight in the event zone",
now: time.Date(2026, 6, 15, 16, 0, 0, 0, pst), // 2026-06-16 00:00 UTC
event: time.Date(2026, 6, 16, 11, 0, 0, 0, ist), // 2026-06-16 05:30 UTC
want: "in 5h 30m",
},
{
name: "tomorrow label shows the event's own wall clock",
now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC),
event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-16 03:30 UTC, 41.5h out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix incorrect UTC date in comment.

The comment says 2026-06-16 03:30 UTC but time.Date(2026, 6, 17, 9, 0, 0, 0, ist) converts to 2026-06-17 03:30 UTC (09:00 − 05:30 = 03:30, same day). The comment is internally inconsistent: "41.5h out" is correct for June 17 but would only be 17.5h for June 16.

Fix the UTC date in the inline comment
 			event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-16 03:30 UTC, 41.5h out

should be:

 			event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-17 03:30 UTC, 41.5h out
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-16 03:30 UTC, 41.5h out
event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-17 03:30 UTC, 41.5h out
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/hookwise/cmd_status_line_calendar_test.go` at line 147, Correct the
inline comment for the event in the test case to state 2026-06-17 03:30 UTC,
preserving the existing “41.5h out” value.

want: "tomorrow 9:00am",
},
{
// 2026-06-15 is a Monday; the event instant is Friday in both zones.
name: "weekday label shows the event's own wall clock",
now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC),
event: time.Date(2026, 6, 19, 14, 30, 0, 0, ist), // 2026-06-19 09:00 UTC
want: "Fri 2:30pm",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, calendarRelativeTime(tc.now, tc.event))
})
}
}
59 changes: 58 additions & 1 deletion internal/bridge/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
goruntime "runtime"
"testing"
"time"

Expand Down Expand Up @@ -870,9 +871,13 @@ func TestFlattenForTUI_CalendarFieldNamesForPythonTUI(t *testing.T) {
require.True(t, ok, "next_event must be a map")
require.Contains(t, next, "name", "next_event must expose 'name', not 'title' (issue #155)")

// Envelope stripped + freshness added.
// Envelope stripped + freshness added. Post-#249 the Python TUI gates
// rendering on updated_at+ttl_seconds, so a flattened entry missing either
// is invisible in production — pin both here, not just updated_at.
assert.NotContains(t, entry, "data")
assert.Equal(t, fixture["timestamp"], entry["updated_at"])
assert.Equal(t, DefaultTTLSeconds, entry["ttl_seconds"],
"flattened calendar must carry ttl_seconds or the TUI freshness gate drops the segment")
}

func TestFlattenForTUI_ProjectFieldNamesForPythonTUI(t *testing.T) {
Expand All @@ -896,6 +901,58 @@ func TestFlattenForTUI_ProjectFieldNamesForPythonTUI(t *testing.T) {
assert.Equal(t, fixture["timestamp"], entry["updated_at"])
}

// ---------------------------------------------------------------------------
// Test 30e: calendar feed contract fixture (shared with tui/tests)
// ---------------------------------------------------------------------------

// calendarFeedFixturePath resolves testdata/contracts/feeds/calendar.json
// relative to the repo root (same walk-up as internal/contract's fixturesDir).
func calendarFeedFixturePath(t *testing.T) string {
t.Helper()
_, filename, _, ok := goruntime.Caller(0)
require.True(t, ok, "runtime.Caller failed")
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename)))
return filepath.Join(repoRoot, "testdata", "contracts", "feeds", "calendar.json")
}

// TestCalendarFeedContractFixture_FlattenForTUI is the Go half of the shared
// calendar feed contract (ARCH-6 spirit for the Go→JSON→Python boundary).
// The same JSON file is rendered by tui/tests/test_status_segments.py, so the
// two sides can only pass together: a producer field rename breaks the
// envelope pin here, and an expected_flattened edit that no longer renders
// breaks the Python side.
func TestCalendarFeedContractFixture_FlattenForTUI(t *testing.T) {
raw, err := os.ReadFile(calendarFeedFixturePath(t))
require.NoError(t, err, "shared calendar feed fixture must exist (tui/tests reads the same file)")

var fixture struct {
Envelope map[string]interface{} `json:"envelope"`
ExpectedFlattened map[string]interface{} `json:"expected_flattened"`
}
require.NoError(t, json.Unmarshal(raw, &fixture))
require.NotEmpty(t, fixture.Envelope, "fixture must carry an envelope")
require.NotEmpty(t, fixture.ExpectedFlattened, "fixture must carry expected_flattened")

// Pin the fixture's envelope to the producer's canonical test fixture so
// the shared file can't drift from what the producer actually emits.
wantEnvelope, err := json.Marshal(feeds.CalendarTestFixture())
require.NoError(t, err)
gotEnvelope, err := json.Marshal(fixture.Envelope)
require.NoError(t, err)
require.JSONEq(t, string(wantEnvelope), string(gotEnvelope),
"fixture envelope must match feeds.CalendarTestFixture(); update both together")

// FlattenForTUI(envelope) must equal the exact flat entry the TUI renders,
// including the updated_at/ttl_seconds freshness fields the TUI gates on.
flat := FlattenForTUI(map[string]interface{}{"calendar": fixture.Envelope})
gotFlat, err := json.Marshal(flat["calendar"])
require.NoError(t, err)
wantFlat, err := json.Marshal(fixture.ExpectedFlattened)
require.NoError(t, err)
assert.JSONEq(t, string(wantFlat), string(gotFlat),
"flattened calendar entry must byte-match the fixture's expected_flattened")
}

// ---------------------------------------------------------------------------
// Test 31: FlattenForTUI preserves ttl_seconds from data if present
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading