detached 8cb9bde

refactor(history): move delete-range policy out of handlers

commit
8cb9bde29545fda05708aebc8bd811f633985de1
author
brice · · 2026-04-11 17:49 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
14d9030
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +141 -31
M internal/application/port/homepage.go +1 -0
@@ -23,6 +23,7 @@ type HomepageHistory interface {
2323 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2424 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
2525 Delete(ctx context.Context, id int64) error
26+ ClearRange(ctx context.Context, rangeID string) error
2627 ClearAll(ctx context.Context) error
2728 ClearOlderThan(ctx context.Context, before time.Time) error
2829 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
@@ -23,6 +23,7 @@ type HomepageHistory interface {
23 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)23 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)24 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
25 Delete(ctx context.Context, id int64) error25 Delete(ctx context.Context, id int64) error
26+ ClearRange(ctx context.Context, rangeID string) error
26 ClearAll(ctx context.Context) error27 ClearAll(ctx context.Context) error
27 ClearOlderThan(ctx context.Context, before time.Time) error28 ClearOlderThan(ctx context.Context, before time.Time) error
28 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)29 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
M internal/application/port/mocks/mocks.go +57 -0
@@ -5641,6 +5641,63 @@ func (_c *MockHomepageHistory_ClearAll_Call) RunAndReturn(run func(ctx context.C
56415641 return _c
56425642 }
56435643
5644+// ClearRange provides a mock function for the type MockHomepageHistory
5645+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
5646+ ret := _mock.Called(ctx, rangeID)
5647+
5648+ if len(ret) == 0 {
5649+ panic("no return value specified for ClearRange")
5650+ }
5651+
5652+ var r0 error
5653+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
5654+ r0 = returnFunc(ctx, rangeID)
5655+ } else {
5656+ r0 = ret.Error(0)
5657+ }
5658+ return r0
5659+}
5660+
5661+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
5662+type MockHomepageHistory_ClearRange_Call struct {
5663+ *mock.Call
5664+}
5665+
5666+// ClearRange is a helper method to define mock.On call
5667+// - ctx context.Context
5668+// - rangeID string
5669+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
5670+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
5671+}
5672+
5673+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
5674+ _c.Call.Run(func(args mock.Arguments) {
5675+ var arg0 context.Context
5676+ if args[0] != nil {
5677+ arg0 = args[0].(context.Context)
5678+ }
5679+ var arg1 string
5680+ if args[1] != nil {
5681+ arg1 = args[1].(string)
5682+ }
5683+ run(
5684+ arg0,
5685+ arg1,
5686+ )
5687+ })
5688+ return _c
5689+}
5690+
5691+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
5692+ _c.Call.Return(err)
5693+ return _c
5694+}
5695+
5696+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
5697+ _c.Call.Return(run)
5698+ return _c
5699+}
5700+
56445701 // ClearOlderThan provides a mock function for the type MockHomepageHistory
56455702 func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {
56465703 ret := _mock.Called(ctx, before)
@@ -5641,6 +5641,63 @@ func (_c *MockHomepageHistory_ClearAll_Call) RunAndReturn(run func(ctx context.C
5641 return _c5641 return _c
5642 }5642 }
5643 5643
5644+// ClearRange provides a mock function for the type MockHomepageHistory
5645+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
5646+ ret := _mock.Called(ctx, rangeID)
5647+
5648+ if len(ret) == 0 {
5649+ panic("no return value specified for ClearRange")
5650+ }
5651+
5652+ var r0 error
5653+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
5654+ r0 = returnFunc(ctx, rangeID)
5655+ } else {
5656+ r0 = ret.Error(0)
5657+ }
5658+ return r0
5659+}
5660+
5661+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
5662+type MockHomepageHistory_ClearRange_Call struct {
5663+ *mock.Call
5664+}
5665+
5666+// ClearRange is a helper method to define mock.On call
5667+// - ctx context.Context
5668+// - rangeID string
5669+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
5670+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
5671+}
5672+
5673+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
5674+ _c.Call.Run(func(args mock.Arguments) {
5675+ var arg0 context.Context
5676+ if args[0] != nil {
5677+ arg0 = args[0].(context.Context)
5678+ }
5679+ var arg1 string
5680+ if args[1] != nil {
5681+ arg1 = args[1].(string)
5682+ }
5683+ run(
5684+ arg0,
5685+ arg1,
5686+ )
5687+ })
5688+ return _c
5689+}
5690+
5691+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
5692+ _c.Call.Return(err)
5693+ return _c
5694+}
5695+
5696+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
5697+ _c.Call.Return(run)
5698+ return _c
5699+}
5700+
5644 // ClearOlderThan provides a mock function for the type MockHomepageHistory5701 // ClearOlderThan provides a mock function for the type MockHomepageHistory
5645 func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {5702 func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {
5646 ret := _mock.Called(ctx, before)5703 ret := _mock.Called(ctx, before)
M internal/application/usecase/search_history.go +11 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/domain/entity"
10+ historydomain "github.com/bnema/dumber/internal/domain/history"
1011 "github.com/bnema/dumber/internal/domain/repository"
1112 "github.com/bnema/dumber/internal/logging"
1213 )
@@ -154,6 +155,16 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
154155 return nil
155156 }
156157
158+// ClearRange deletes history entries for a named range.
159+func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
160+ cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now())
161+ if all {
162+ return uc.ClearAll(ctx)
163+ }
164+
165+ return uc.ClearOlderThan(ctx, cutoff)
166+}
167+
157168 // ClearAll deletes all history entries.
158169 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
159170 log := logging.FromContext(ctx)
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10+ historydomain "github.com/bnema/dumber/internal/domain/history"
10 "github.com/bnema/dumber/internal/domain/repository"11 "github.com/bnema/dumber/internal/domain/repository"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 )13 )
@@ -154,6 +155,16 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
154 return nil155 return nil
155 }156 }
156 157
158+// ClearRange deletes history entries for a named range.
159+func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
160+ cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now())
161+ if all {
162+ return uc.ClearAll(ctx)
163+ }
164+
165+ return uc.ClearOlderThan(ctx, cutoff)
166+}
167+
157 // ClearAll deletes all history entries.168 // ClearAll deletes all history entries.
158 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {169 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
159 log := logging.FromContext(ctx)170 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +13 -0
@@ -151,3 +151,16 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
151151 require.NotNil(t, result)
152152 assert.Empty(t, result)
153153 }
154+
155+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
156+ ctx := testContext()
157+
158+ historyRepo := repomocks.NewMockHistoryRepository(t)
159+
160+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
161+
162+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
163+
164+ err := uc.ClearRange(ctx, "all")
165+ require.NoError(t, err)
166+}
@@ -151,3 +151,16 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
151 require.NotNil(t, result)151 require.NotNil(t, result)
152 assert.Empty(t, result)152 assert.Empty(t, result)
153 }153 }
154+
155+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
156+ ctx := testContext()
157+
158+ historyRepo := repomocks.NewMockHistoryRepository(t)
159+
160+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
161+
162+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
163+
164+ err := uc.ClearRange(ctx, "all")
165+ require.NoError(t, err)
166+}
A internal/domain/history/delete_range.go +22 -0
@@ -0,0 +1,22 @@
1+package history
2+
3+import "time"
4+
5+// DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6+// The boolean reports whether the range means "delete all".
7+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) {
8+ switch rangeID {
9+ case "hour":
10+ return now.Add(-time.Hour), false
11+ case "day":
12+ return now.AddDate(0, 0, -1), false
13+ case "week":
14+ return now.AddDate(0, 0, -7), false
15+ case "month":
16+ return now.AddDate(0, -1, 0), false
17+ case "all":
18+ return time.Time{}, true
19+ default:
20+ return now, false
21+ }
22+}
@@ -0,0 +1,22 @@
1+package history
2+
3+import "time"
4+
5+// DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6+// The boolean reports whether the range means "delete all".
7+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) {
8+ switch rangeID {
9+ case "hour":
10+ return now.Add(-time.Hour), false
11+ case "day":
12+ return now.AddDate(0, 0, -1), false
13+ case "week":
14+ return now.AddDate(0, 0, -7), false
15+ case "month":
16+ return now.AddDate(0, -1, 0), false
17+ case "all":
18+ return time.Time{}, true
19+ default:
20+ return now, false
21+ }
22+}
A internal/domain/history/delete_range_test.go +35 -0
@@ -0,0 +1,35 @@
1+package history
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestDeleteRangeCutoff(t *testing.T) {
9+ now := time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC)
10+
11+ tests := []struct {
12+ name string
13+ rangeID string
14+ want time.Time
15+ wantAll bool
16+ }{
17+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false},
18+ {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false},
19+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false},
20+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false},
21+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true},
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ got, gotAll := DeleteRangeCutoff(tt.rangeID, now)
27+ if !got.Equal(tt.want) {
28+ t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
29+ }
30+ if gotAll != tt.wantAll {
31+ t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
32+ }
33+ })
34+ }
35+}
@@ -0,0 +1,35 @@
1+package history
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestDeleteRangeCutoff(t *testing.T) {
9+ now := time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC)
10+
11+ tests := []struct {
12+ name string
13+ rangeID string
14+ want time.Time
15+ wantAll bool
16+ }{
17+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false},
18+ {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false},
19+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false},
20+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false},
21+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true},
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ got, gotAll := DeleteRangeCutoff(tt.rangeID, now)
27+ if !got.Equal(tt.want) {
28+ t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
29+ }
30+ if gotAll != tt.wantAll {
31+ t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
32+ }
33+ })
34+ }
35+}
M internal/infrastructure/handlers/homepage/history.go +2 -31
@@ -3,7 +3,6 @@ package homepage
33 import (
44 "context"
55 "encoding/json"
6- "time"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/domain/entity"
@@ -142,16 +141,8 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
142141 Str("range", req.Range).
143142 Msg("handling history_delete_range")
144143
145- before := rangeToTime(req.Range)
146- if before.IsZero() {
147- // "all" case - clear all
148- if err := h.historyUC.ClearAll(ctx); err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151- } else {
152- if err := h.historyUC.ClearOlderThan(ctx, before); err != nil {
153- return NewErrorResponse(req.RequestID, err), nil
154- }
144+ if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
145+ return NewErrorResponse(req.RequestID, err), nil
155146 }
156147
157148 return NewSuccessResponse(req.RequestID, nil), nil
@@ -177,26 +168,6 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
177168 })
178169 }
179170
180-// rangeToTime converts a range string to a time.Time cutoff.
181-// Returns zero time for "all" which means delete everything.
182-func rangeToTime(r string) time.Time {
183- now := time.Now()
184- switch r {
185- case "hour":
186- return now.Add(-time.Hour)
187- case "day":
188- return now.AddDate(0, 0, -1)
189- case "week":
190- return now.AddDate(0, 0, -7)
191- case "month":
192- return now.AddDate(0, -1, 0)
193- case "all":
194- return time.Time{} // Zero time signals "delete all"
195- default:
196- return now
197- }
198-}
199-
200171 // HandleAnalytics handles history_analytics messages.
201172 func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {
202173 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -3,7 +3,6 @@ package homepage
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6- "time"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
@@ -142,16 +141,8 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
142 Str("range", req.Range).141 Str("range", req.Range).
143 Msg("handling history_delete_range")142 Msg("handling history_delete_range")
144 143
145- before := rangeToTime(req.Range)144+ if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
146- if before.IsZero() {145+ return NewErrorResponse(req.RequestID, err), nil
147- // "all" case - clear all
148- if err := h.historyUC.ClearAll(ctx); err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151- } else {
152- if err := h.historyUC.ClearOlderThan(ctx, before); err != nil {
153- return NewErrorResponse(req.RequestID, err), nil
154- }
155 }146 }
156 147
157 return NewSuccessResponse(req.RequestID, nil), nil148 return NewSuccessResponse(req.RequestID, nil), nil
@@ -177,26 +168,6 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
177 })168 })
178 }169 }
179 170
180-// rangeToTime converts a range string to a time.Time cutoff.
181-// Returns zero time for "all" which means delete everything.
182-func rangeToTime(r string) time.Time {
183- now := time.Now()
184- switch r {
185- case "hour":
186- return now.Add(-time.Hour)
187- case "day":
188- return now.AddDate(0, 0, -1)
189- case "week":
190- return now.AddDate(0, 0, -7)
191- case "month":
192- return now.AddDate(0, -1, 0)
193- case "all":
194- return time.Time{} // Zero time signals "delete all"
195- default:
196- return now
197- }
198-}
199-
200 // HandleAnalytics handles history_analytics messages.171 // HandleAnalytics handles history_analytics messages.
201 func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {172 func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {
202 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {173 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close