From 2f646a71bc59a611f56459a0ca01a3e2e9e18c46 Mon Sep 17 00:00:00 2001 From: colangelo Date: Mon, 23 Feb 2026 22:04:52 +0100 Subject: [PATCH 1/3] chore: add connection-timeline openspec change artifacts Proposal, design, specs, and tasks for tracking alternating UP/DOWN connectivity periods and displaying a timeline in the exit summary. Co-Authored-By: Claude Opus 4.6 --- .../connection-timeline/.openspec.yaml | 2 + .../changes/connection-timeline/design.md | 49 +++++++++++++ .../changes/connection-timeline/proposal.md | 23 ++++++ .../specs/connection-timeline/spec.md | 70 +++++++++++++++++++ openspec/changes/connection-timeline/tasks.md | 29 ++++++++ 5 files changed, 173 insertions(+) create mode 100644 openspec/changes/connection-timeline/.openspec.yaml create mode 100644 openspec/changes/connection-timeline/design.md create mode 100644 openspec/changes/connection-timeline/proposal.md create mode 100644 openspec/changes/connection-timeline/specs/connection-timeline/spec.md create mode 100644 openspec/changes/connection-timeline/tasks.md diff --git a/openspec/changes/connection-timeline/.openspec.yaml b/openspec/changes/connection-timeline/.openspec.yaml new file mode 100644 index 0000000..eac8ef7 --- /dev/null +++ b/openspec/changes/connection-timeline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-23 diff --git a/openspec/changes/connection-timeline/design.md b/openspec/changes/connection-timeline/design.md new file mode 100644 index 0000000..1f978e4 --- /dev/null +++ b/openspec/changes/connection-timeline/design.md @@ -0,0 +1,49 @@ +## Context + +hp currently tracks total failures (`s.failures`) as a single counter. When a request fails, the counter increments and a red `!` block is appended. The final summary shows total requests, ok, failed, and loss percentage — but no temporal information about when failures occurred or how they clustered. + +The `stats` struct (main.go:86-99) holds all monitoring state. The main loop (main.go:278-383) processes each request result. `printFinal()` (main.go:679-701) renders the exit summary. + +## Goals / Non-Goals + +**Goals:** +- Track alternating UP (successful) and DOWN (failure) periods with start times and request counts +- Display a color-coded timeline in the final summary showing the connectivity pattern +- Make intermittent outage diagnosis trivial ("drops every ~2min after recovery") +- Zero visual noise when there are no failures + +**Non-Goals:** +- No new CLI flags — timeline appears automatically when relevant +- No live/inline timeline display during monitoring — summary only +- No persistence or export of timeline data +- No per-request timestamp tracking — only period boundaries + +## Decisions + +### 1. Period-based model over event log + +Track `[]period` (alternating up/down) rather than individual request timestamps. This is O(transitions) not O(requests), keeps memory bounded for long sessions, and directly represents the pattern users care about. + +Alternative: Per-request timestamps — rejected because it's wasteful (most sessions are thousands of requests) and requires post-processing to find patterns. + +### 2. Inline state tracking in main loop + +Add period transition logic directly in the main loop (after line 280 for failures, after line 344 for successes) rather than a separate goroutine or observer. The main loop already has the success/fail distinction and runs sequentially — no need for additional complexity. + +### 3. Close active period before printFinal + +Before calling `printFinal()` (at Ctrl+C handler line 208 and count-exit line 373), close the current period by appending it to `s.periods`. This ensures the last period appears in the timeline. The last period gets a special "active" label since it was ongoing at exit. + +### 4. Timeline only shown when failures > 0 + +If all requests succeed, there's only one UP period — showing "timeline: UP 5m (300 ok)" adds no value. Only print the timeline section when `s.failures > 0`, keeping output clean for healthy targets. + +### 5. Compact duration formatting + +Use a custom `fmtDuration()` returning strings like "7s", "2m13s", "1h02m" rather than Go's default `Duration.String()` which produces "2m13.000000s". The compact format is more readable in the fixed-width timeline layout. + +## Risks / Trade-offs + +- [Minimal memory overhead] → Each period is ~25 bytes (bool + Time + int). Even with thousands of transitions, this is negligible. No mitigation needed. +- [Timeline output length for very flaky connections] → A connection flapping every second could produce hundreds of periods. → Mitigation: Cap displayed periods (e.g., first 5 + last 5 with "... N more ..." in between) if list exceeds a threshold. +- [Time zone display] → Use local time (time.Now()) which matches user expectations. Users running across time zones can set TZ env var. diff --git a/openspec/changes/connection-timeline/proposal.md b/openspec/changes/connection-timeline/proposal.md new file mode 100644 index 0000000..acf5704 --- /dev/null +++ b/openspec/changes/connection-timeline/proposal.md @@ -0,0 +1,23 @@ +## Why + +When diagnosing intermittent connectivity issues (VPN drops, flaky proxies, unstable links), knowing *when* outages happen and how long the connection stays up between them is critical. Currently hp only reports total loss percentage — it discards the temporal pattern. Users need to see "it drops every ~2 minutes after recovery" at a glance. + +## What Changes + +- Track alternating UP/DOWN periods during the monitoring session, recording start time and request count for each +- Display a color-coded timeline in the final summary (on Ctrl+C or `-c` count completion) +- Timeline only appears when there were failures — zero noise for clean sessions +- Add a compact duration formatter for human-readable period lengths + +## Capabilities + +### New Capabilities +- `connection-timeline`: Track alternating UP/DOWN connectivity periods and display a timeline summary showing start times, durations, and request counts for each period + +### Modified Capabilities + +## Impact + +- `main.go`: New `period` struct, new fields on `stats`, main loop state tracking, `printFinal()` timeline output, `fmtDuration()` helper +- `main_test.go`: Tests for `fmtDuration()` and period tracking logic +- No new dependencies, no flag changes, no breaking changes diff --git a/openspec/changes/connection-timeline/specs/connection-timeline/spec.md b/openspec/changes/connection-timeline/specs/connection-timeline/spec.md new file mode 100644 index 0000000..12968f6 --- /dev/null +++ b/openspec/changes/connection-timeline/specs/connection-timeline/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Period tracking +The system SHALL track alternating UP and DOWN periods during monitoring. A period starts when the connection state changes (success→failure or failure→success) or on the first request. Each period SHALL record whether it is UP or DOWN, the start time, and the number of requests in that period. + +#### Scenario: First request succeeds +- **WHEN** the first request completes successfully +- **THEN** a new UP period is created with start time of the request and count of 1 + +#### Scenario: First request fails +- **WHEN** the first request fails +- **THEN** a new DOWN period is created with start time of the request and count of 1 + +#### Scenario: Consecutive successes +- **WHEN** a request succeeds and the current period is UP +- **THEN** the current period's count is incremented by 1 + +#### Scenario: Consecutive failures +- **WHEN** a request fails and the current period is DOWN +- **THEN** the current period's count is incremented by 1 + +#### Scenario: Transition from UP to DOWN +- **WHEN** a request fails and the current period is UP +- **THEN** the current UP period is closed and appended to the completed periods list +- **AND** a new DOWN period is created with start time of the request and count of 1 + +#### Scenario: Transition from DOWN to UP +- **WHEN** a request succeeds and the current period is DOWN +- **THEN** the current DOWN period is closed and appended to the completed periods list +- **AND** a new UP period is created with start time of the request and count of 1 + +### Requirement: Timeline summary display +The system SHALL display a timeline section in the final summary when there were any failures during the session. The timeline SHALL NOT be displayed when all requests succeeded. + +#### Scenario: Summary with failures shows timeline +- **WHEN** the session ends (Ctrl+C or count limit) and there were failures +- **THEN** the summary includes a "timeline:" section after the existing stats +- **AND** each period is shown on its own line with: start time (HH:MM:SS), UP or DOWN label, duration, and request count +- **AND** UP periods are displayed in green, DOWN periods in red +- **AND** the last period shows "active" instead of duration since it was ongoing at exit + +#### Scenario: Clean session hides timeline +- **WHEN** the session ends and there were zero failures +- **THEN** no timeline section is displayed + +#### Scenario: Silent mode hides timeline +- **WHEN** the `--silent` flag is set +- **THEN** no timeline section is displayed (along with the rest of the summary) + +### Requirement: Compact duration formatting +The system SHALL format durations in a compact human-readable form for the timeline display. + +#### Scenario: Seconds only +- **WHEN** a duration is less than 60 seconds +- **THEN** it is formatted as "{N}s" (e.g., "7s", "45s") + +#### Scenario: Minutes and seconds +- **WHEN** a duration is 60 seconds or more but less than 1 hour +- **THEN** it is formatted as "{M}m{SS}s" (e.g., "2m13s", "15m00s") + +#### Scenario: Hours and minutes +- **WHEN** a duration is 1 hour or more +- **THEN** it is formatted as "{H}h{MM}m" (e.g., "1h02m", "3h45m") + +### Requirement: Timeline truncation for flappy connections +The system SHALL truncate the timeline display when the number of periods exceeds a reasonable threshold to prevent excessive output. + +#### Scenario: Many periods truncated +- **WHEN** the total number of periods exceeds 20 +- **THEN** the first 5 and last 5 periods are shown with a "... N more ..." line in between diff --git a/openspec/changes/connection-timeline/tasks.md b/openspec/changes/connection-timeline/tasks.md new file mode 100644 index 0000000..d07c493 --- /dev/null +++ b/openspec/changes/connection-timeline/tasks.md @@ -0,0 +1,29 @@ +## 1. Data Model + +- [ ] 1.1 Add `period` struct to main.go (fields: `up bool`, `start time.Time`, `count int`) +- [ ] 1.2 Add period tracking fields to `stats` struct: `periods []period`, `currentPeriod *period` + +## 2. Core Tracking Logic + +- [ ] 2.1 Add `recordPeriod(s *stats, up bool)` helper that handles period creation and transitions +- [ ] 2.2 Call `recordPeriod` in the main loop failure branch (after line 281) +- [ ] 2.3 Call `recordPeriod` in the main loop success branch (after line 345) +- [ ] 2.4 Add `closePeriods(s *stats)` helper that closes the active period by appending it to `s.periods` +- [ ] 2.5 Call `closePeriods` before `printFinal` in Ctrl+C handler (line ~208) and count-exit path (line ~373) + +## 3. Duration Formatting + +- [ ] 3.1 Implement `fmtDuration(d time.Duration) string` with compact format (7s, 2m13s, 1h02m) + +## 4. Timeline Display + +- [ ] 4.1 Add timeline rendering to `printFinal()`: print "timeline:" header and each period line with start time, UP/DOWN label (color-coded), duration, and count +- [ ] 4.2 Mark the last period with "active" instead of duration +- [ ] 4.3 Only show timeline when `s.failures > 0` +- [ ] 4.4 Implement truncation: show first 5 + last 5 with "... N more ..." when periods exceed 20 + +## 5. Tests + +- [ ] 5.1 Add table-driven tests for `fmtDuration` covering seconds, minutes+seconds, hours+minutes edge cases +- [ ] 5.2 Add tests for `recordPeriod` verifying period creation, continuation, and transitions +- [ ] 5.3 Build and run all tests to verify no regressions From f9cc7f26375a6d33ce5f27c6b7520d009c4b4485 Mon Sep 17 00:00:00 2001 From: colangelo Date: Mon, 23 Feb 2026 22:10:21 +0100 Subject: [PATCH 2/3] feat: add connection timeline tracking to exit summary Track alternating UP/DOWN periods during monitoring. On exit, display a color-coded timeline showing start times, durations, and request counts for each period. Helps diagnose intermittent outages by making temporal patterns visible (e.g., "drops every ~2min after recovery"). Timeline only appears when there were failures. Truncates to first 5 + last 5 periods if connection is very flappy (>20 transitions). Co-Authored-By: Claude Opus 4.6 --- main.go | 117 ++++++++++++-- main_test.go | 148 ++++++++++++++++++ openspec/changes/connection-timeline/tasks.md | 30 ++-- 3 files changed, 268 insertions(+), 27 deletions(-) diff --git a/main.go b/main.go index 9764c6b..d2875c2 100644 --- a/main.go +++ b/main.go @@ -83,19 +83,49 @@ func getEnvInt(key string, def int64) int64 { return def } +type period struct { + up bool + start time.Time + count int +} + type stats struct { - count int - failures int - total time.Duration - min time.Duration - max time.Duration - last time.Duration - blocks []string // individual blocks for proper width handling - col int // current column position on bar line - lastPrinted int // last block index printed - braille bool // braille mode enabled - pendingRTT time.Duration // pending RTT for braille pairing (-1 = failure, 0 = none) - hasPending bool // whether there's a pending RTT + count int + failures int + total time.Duration + min time.Duration + max time.Duration + last time.Duration + blocks []string // individual blocks for proper width handling + col int // current column position on bar line + lastPrinted int // last block index printed + braille bool // braille mode enabled + pendingRTT time.Duration // pending RTT for braille pairing (-1 = failure, 0 = none) + hasPending bool // whether there's a pending RTT + periods []period // completed UP/DOWN periods + currentPeriod *period // active period (nil until first request) +} + +func recordPeriod(s *stats, up bool) { + now := time.Now() + if s.currentPeriod == nil { + s.currentPeriod = &period{up: up, start: now, count: 1} + return + } + if s.currentPeriod.up == up { + s.currentPeriod.count++ + return + } + // State flipped — close current period and start new one + s.periods = append(s.periods, *s.currentPeriod) + s.currentPeriod = &period{up: up, start: now, count: 1} +} + +func closePeriods(s *stats) { + if s.currentPeriod != nil { + s.periods = append(s.periods, *s.currentPeriod) + s.currentPeriod = nil + } } func main() { @@ -204,6 +234,7 @@ func main() { signal.Notify(sigCh, os.Interrupt) go func() { <-sigCh + closePeriods(s) if !*silent { printFinal(displayURL, s) } @@ -280,6 +311,7 @@ func main() { if err != nil { s.failures++ consecutiveFailures++ + recordPeriod(s, false) if s.braille { if s.hasPending { // Pair with pending: pending=left, failure=right @@ -344,6 +376,7 @@ func main() { } else { s.count++ consecutiveFailures = 0 // Reset on success + recordPeriod(s, true) s.total += rtt s.last = rtt if rtt < s.min { @@ -369,6 +402,7 @@ func main() { printDisplay(s) requestNum++ if *count > 0 && requestNum >= *count { + closePeriods(s) if !*silent { printFinal(displayURL, s) } @@ -676,6 +710,20 @@ func printStats(s *stats, width int) { fmt.Printf("\n%s%s%s%s\033[%dG", col0, clearLn, statsText, up, s.col+1) } +func fmtDuration(d time.Duration) string { + d = d.Truncate(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + sec := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh%02dm", h, m) + } + if m > 0 { + return fmt.Sprintf("%dm%02ds", m, sec) + } + return fmt.Sprintf("%ds", sec) +} + func printFinal(url string, s *stats) { total := s.count + s.failures var lossPct int @@ -698,4 +746,49 @@ func printFinal(url string, s *stats) { if s.count > 0 { fmt.Printf("round-trip min/avg/max = %d/%d/%d ms\n", minMs, avg.Milliseconds(), s.max.Milliseconds()) } + + if s.failures > 0 && len(s.periods) > 0 { + fmt.Printf("%stimeline:%s\n", gray, reset) + periods := s.periods + truncated := 0 + if len(periods) > 20 { + truncated = len(periods) - 10 + head := periods[:5] + tail := periods[len(periods)-5:] + periods = append(head, tail...) + } + now := time.Now() + for i, p := range periods { + ts := p.start.Format("15:04:05") + var label, color, detail string + if p.up { + label = "UP " + color = green + detail = fmt.Sprintf("(%d ok)", p.count) + } else { + label = "DOWN" + color = red + detail = fmt.Sprintf("(%d lost)", p.count) + } + isLast := i == len(periods)-1 && truncated == 0 || + i == len(periods)-1 && truncated > 0 + + if isLast { + fmt.Printf(" %s %s%s%s %s← active%s\n", ts, color, label, reset, gray, reset) + } else { + var end time.Time + if i+1 < len(periods) { + end = periods[i+1].start + } else { + end = now + } + dur := fmtDuration(end.Sub(p.start)) + fmt.Printf(" %s %s%s%s %6s %s\n", ts, color, label, reset, dur, detail) + } + + if truncated > 0 && i == 4 { + fmt.Printf(" %s... %d more ...%s\n", gray, truncated, reset) + } + } + } } diff --git a/main_test.go b/main_test.go index 9adb7df..fb42146 100644 --- a/main_test.go +++ b/main_test.go @@ -584,6 +584,154 @@ func TestTruncateToWidth_TableDriven(t *testing.T) { } } +// ============================================================================= +// Test: fmtDuration function tests +// ============================================================================= + +func TestFmtDuration_TableDriven(t *testing.T) { + tests := []struct { + name string + d time.Duration + want string + }{ + {"zero", 0, "0s"}, + {"one-second", time.Second, "1s"}, + {"seconds", 45 * time.Second, "45s"}, + {"one-minute", time.Minute, "1m00s"}, + {"minutes-seconds", 2*time.Minute + 13*time.Second, "2m13s"}, + {"exact-minutes", 15 * time.Minute, "15m00s"}, + {"one-hour", time.Hour, "1h00m"}, + {"hours-minutes", time.Hour + 2*time.Minute, "1h02m"}, + {"large", 3*time.Hour + 45*time.Minute, "3h45m"}, + {"subsecond-truncated", 500 * time.Millisecond, "0s"}, + {"59-seconds", 59 * time.Second, "59s"}, + {"60-seconds", 60 * time.Second, "1m00s"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := fmtDuration(tc.d) + if got != tc.want { + t.Errorf("fmtDuration(%v) = %q; want %q", tc.d, got, tc.want) + } + }) + } +} + +// ============================================================================= +// Test: recordPeriod function tests +// ============================================================================= + +func TestRecordPeriod(t *testing.T) { + t.Run("first-success-creates-up", func(t *testing.T) { + s := &stats{} + recordPeriod(s, true) + if s.currentPeriod == nil { + t.Fatal("currentPeriod is nil") + } + if !s.currentPeriod.up { + t.Error("expected UP period") + } + if s.currentPeriod.count != 1 { + t.Errorf("count = %d; want 1", s.currentPeriod.count) + } + if len(s.periods) != 0 { + t.Errorf("periods = %d; want 0", len(s.periods)) + } + }) + + t.Run("first-failure-creates-down", func(t *testing.T) { + s := &stats{} + recordPeriod(s, false) + if s.currentPeriod == nil { + t.Fatal("currentPeriod is nil") + } + if s.currentPeriod.up { + t.Error("expected DOWN period") + } + if s.currentPeriod.count != 1 { + t.Errorf("count = %d; want 1", s.currentPeriod.count) + } + }) + + t.Run("consecutive-same-increments", func(t *testing.T) { + s := &stats{} + recordPeriod(s, true) + recordPeriod(s, true) + recordPeriod(s, true) + if s.currentPeriod.count != 3 { + t.Errorf("count = %d; want 3", s.currentPeriod.count) + } + if len(s.periods) != 0 { + t.Errorf("periods = %d; want 0", len(s.periods)) + } + }) + + t.Run("transition-closes-period", func(t *testing.T) { + s := &stats{} + recordPeriod(s, true) + recordPeriod(s, true) + recordPeriod(s, false) // transition + if len(s.periods) != 1 { + t.Fatalf("periods = %d; want 1", len(s.periods)) + } + if !s.periods[0].up { + t.Error("closed period should be UP") + } + if s.periods[0].count != 2 { + t.Errorf("closed period count = %d; want 2", s.periods[0].count) + } + if s.currentPeriod.up { + t.Error("current period should be DOWN") + } + if s.currentPeriod.count != 1 { + t.Errorf("current count = %d; want 1", s.currentPeriod.count) + } + }) + + t.Run("multiple-transitions", func(t *testing.T) { + s := &stats{} + recordPeriod(s, true) // UP + recordPeriod(s, true) // UP (2) + recordPeriod(s, false) // DOWN + recordPeriod(s, false) // DOWN (2) + recordPeriod(s, false) // DOWN (3) + recordPeriod(s, true) // UP again + if len(s.periods) != 2 { + t.Fatalf("periods = %d; want 2", len(s.periods)) + } + if !s.periods[0].up || s.periods[0].count != 2 { + t.Errorf("period[0]: up=%v count=%d; want up=true count=2", s.periods[0].up, s.periods[0].count) + } + if s.periods[1].up || s.periods[1].count != 3 { + t.Errorf("period[1]: up=%v count=%d; want up=false count=3", s.periods[1].up, s.periods[1].count) + } + if !s.currentPeriod.up || s.currentPeriod.count != 1 { + t.Errorf("current: up=%v count=%d; want up=true count=1", s.currentPeriod.up, s.currentPeriod.count) + } + }) + + t.Run("close-periods", func(t *testing.T) { + s := &stats{} + recordPeriod(s, true) + recordPeriod(s, false) + closePeriods(s) + if len(s.periods) != 2 { + t.Fatalf("periods = %d; want 2", len(s.periods)) + } + if s.currentPeriod != nil { + t.Error("currentPeriod should be nil after close") + } + }) + + t.Run("close-nil-noop", func(t *testing.T) { + s := &stats{} + closePeriods(s) // should not panic + if len(s.periods) != 0 { + t.Errorf("periods = %d; want 0", len(s.periods)) + } + }) +} + func TestGetEnvInt_TableDriven(t *testing.T) { tests := []struct { name string diff --git a/openspec/changes/connection-timeline/tasks.md b/openspec/changes/connection-timeline/tasks.md index d07c493..4dc8e11 100644 --- a/openspec/changes/connection-timeline/tasks.md +++ b/openspec/changes/connection-timeline/tasks.md @@ -1,29 +1,29 @@ ## 1. Data Model -- [ ] 1.1 Add `period` struct to main.go (fields: `up bool`, `start time.Time`, `count int`) -- [ ] 1.2 Add period tracking fields to `stats` struct: `periods []period`, `currentPeriod *period` +- [x] 1.1 Add `period` struct to main.go (fields: `up bool`, `start time.Time`, `count int`) +- [x] 1.2 Add period tracking fields to `stats` struct: `periods []period`, `currentPeriod *period` ## 2. Core Tracking Logic -- [ ] 2.1 Add `recordPeriod(s *stats, up bool)` helper that handles period creation and transitions -- [ ] 2.2 Call `recordPeriod` in the main loop failure branch (after line 281) -- [ ] 2.3 Call `recordPeriod` in the main loop success branch (after line 345) -- [ ] 2.4 Add `closePeriods(s *stats)` helper that closes the active period by appending it to `s.periods` -- [ ] 2.5 Call `closePeriods` before `printFinal` in Ctrl+C handler (line ~208) and count-exit path (line ~373) +- [x] 2.1 Add `recordPeriod(s *stats, up bool)` helper that handles period creation and transitions +- [x] 2.2 Call `recordPeriod` in the main loop failure branch (after line 281) +- [x] 2.3 Call `recordPeriod` in the main loop success branch (after line 345) +- [x] 2.4 Add `closePeriods(s *stats)` helper that closes the active period by appending it to `s.periods` +- [x] 2.5 Call `closePeriods` before `printFinal` in Ctrl+C handler (line ~208) and count-exit path (line ~373) ## 3. Duration Formatting -- [ ] 3.1 Implement `fmtDuration(d time.Duration) string` with compact format (7s, 2m13s, 1h02m) +- [x] 3.1 Implement `fmtDuration(d time.Duration) string` with compact format (7s, 2m13s, 1h02m) ## 4. Timeline Display -- [ ] 4.1 Add timeline rendering to `printFinal()`: print "timeline:" header and each period line with start time, UP/DOWN label (color-coded), duration, and count -- [ ] 4.2 Mark the last period with "active" instead of duration -- [ ] 4.3 Only show timeline when `s.failures > 0` -- [ ] 4.4 Implement truncation: show first 5 + last 5 with "... N more ..." when periods exceed 20 +- [x] 4.1 Add timeline rendering to `printFinal()`: print "timeline:" header and each period line with start time, UP/DOWN label (color-coded), duration, and count +- [x] 4.2 Mark the last period with "active" instead of duration +- [x] 4.3 Only show timeline when `s.failures > 0` +- [x] 4.4 Implement truncation: show first 5 + last 5 with "... N more ..." when periods exceed 20 ## 5. Tests -- [ ] 5.1 Add table-driven tests for `fmtDuration` covering seconds, minutes+seconds, hours+minutes edge cases -- [ ] 5.2 Add tests for `recordPeriod` verifying period creation, continuation, and transitions -- [ ] 5.3 Build and run all tests to verify no regressions +- [x] 5.1 Add table-driven tests for `fmtDuration` covering seconds, minutes+seconds, hours+minutes edge cases +- [x] 5.2 Add tests for `recordPeriod` verifying period creation, continuation, and transitions +- [x] 5.3 Build and run all tests to verify no regressions From d158030f7c5f2abc56b24428ab56d164130cd6ac Mon Sep 17 00:00:00 2001 From: colangelo Date: Mon, 23 Feb 2026 22:20:26 +0100 Subject: [PATCH 3/3] fix: delete local beta tag before recreating release gh release create fails when the tag exists locally but not remotely. Delete the local tag after removing the remote one. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index bfdae89..14cc3cb 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -84,6 +84,7 @@ jobs: # Delete existing beta release and tag if present gh release delete beta --yes 2>/dev/null || true git push origin :refs/tags/beta 2>/dev/null || true + git tag -d beta 2>/dev/null || true # Create new pre-release gh release create beta \