detached cebf030

feat(dmenu): change history limit from count to days, add --most-visited flag

commit
cebf030b7b30d532c4bc50c196e86e3d6b21aabe
author
bnema · · 2025-12-29 19:13 UTC
parent
76a4000
signature
GPG Unverified2B82207D370127A0 The key is unknown
18 files · +722 -20
M README.md +2 -1
@@ -251,7 +251,8 @@ sudo apt install va-driver-all
251251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
252252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254- - `dumber dmenu --max 200` # limit output entries
254+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
255+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
255256 - Manage browsing history:
256257 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
257258 - `dumber history --json` # output recent entries as JSON
@@ -251,7 +251,8 @@ sudo apt install va-driver-all
251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254- - `dumber dmenu --max 200` # limit output entries254+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
255+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
255 - Manage browsing history:256 - Manage browsing history:
256 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)257 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
257 - `dumber history --json` # output recent entries as JSON258 - `dumber history --json` # output recent entries as JSON
M docs/CONFIG.md +10 -4
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
8383
8484 | Key | Type | Default | Description |
8585 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |
86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
8787 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
8888 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
8989 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
9090 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
9191 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
9292 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |
93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
9494
9595 **CLI Usage:**
9696 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101101 # Interactive TUI mode
102102 dumber dmenu --interactive
103103
104-# Override max items via CLI flag
105-dumber dmenu --max 50
104+# Override history days via CLI flag (show last 7 days)
105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106112 ```
107113
108114 ## Omnibox
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
83 83
84 | Key | Type | Default | Description |84 | Key | Type | Default | Description |
85 |-----|------|---------|-------------|85 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
94 94
95 **CLI Usage:**95 **CLI Usage:**
96 ```bash96 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101 # Interactive TUI mode101 # Interactive TUI mode
102 dumber dmenu --interactive102 dumber dmenu --interactive
103 103
104-# Override max items via CLI flag104+# Override history days via CLI flag (show last 7 days)
105-dumber dmenu --max 50105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106 ```112 ```
107 113
108 ## Omnibox114 ## Omnibox
M internal/application/usecase/search_history.go +28 -0
@@ -75,6 +75,34 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7575 return entries, nil
7676 }
7777
78+// GetRecentSince retrieves history entries visited within the last N days.
79+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
80+ if days <= 0 {
81+ days = 30 // Default to 30 days
82+ }
83+
84+ entries, err := uc.historyRepo.GetRecentSince(ctx, days)
85+ if err != nil {
86+ return nil, fmt.Errorf("failed to get recent history since %d days: %w", days, err)
87+ }
88+
89+ return entries, nil
90+}
91+
92+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
93+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
94+ if days <= 0 {
95+ days = 30 // Default to 30 days
96+ }
97+
98+ entries, err := uc.historyRepo.GetMostVisited(ctx, days)
99+ if err != nil {
100+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
101+ }
102+
103+ return entries, nil
104+}
105+
78106 // FindByURL retrieves a history entry by its URL.
79107 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80108 log := logging.FromContext(ctx)
@@ -75,6 +75,34 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
75 return entries, nil75 return entries, nil
76 }76 }
77 77
78+// GetRecentSince retrieves history entries visited within the last N days.
79+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
80+ if days <= 0 {
81+ days = 30 // Default to 30 days
82+ }
83+
84+ entries, err := uc.historyRepo.GetRecentSince(ctx, days)
85+ if err != nil {
86+ return nil, fmt.Errorf("failed to get recent history since %d days: %w", days, err)
87+ }
88+
89+ return entries, nil
90+}
91+
92+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
93+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
94+ if days <= 0 {
95+ days = 30 // Default to 30 days
96+ }
97+
98+ entries, err := uc.historyRepo.GetMostVisited(ctx, days)
99+ if err != nil {
100+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
101+ }
102+
103+ return entries, nil
104+}
105+
78 // FindByURL retrieves a history entry by its URL.106 // FindByURL retrieves a history entry by its URL.
79 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {107 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80 log := logging.FromContext(ctx)108 log := logging.FromContext(ctx)
A internal/application/usecase/search_history_test.go +153 -0
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_DefaultsTo30Days(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days <= 0, should default to 30
47+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history since 7 days")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_DefaultsTo30Days(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days <= 0, should default to 30
116+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_DefaultsTo30Days(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days <= 0, should default to 30
47+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history since 7 days")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_DefaultsTo30Days(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days <= 0, should default to 30
116+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
M internal/cli/cmd/dmenu.go +21 -6
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/dumber/assets"
1515 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
1617 domainurl "github.com/bnema/dumber/internal/domain/url"
1718 "github.com/bnema/dumber/internal/infrastructure/config"
1819 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -22,7 +23,8 @@ import (
2223 var (
2324 dmenuInteractive bool
2425 dmenuSelect bool
25- dmenuMax int
26+ dmenuDays int
27+ dmenuMostVisited bool
2628 )
2729
2830 var dmenuCmd = &cobra.Command{
@@ -43,7 +45,8 @@ func init() {
4345
4446 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
4547 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
4750 }
4851
4952 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
7174 app := GetApp()
7275 cfg := app.Config.Dmenu
7376
74- // Override max if explicitly set via CLI flag
75- if dmenuMax > 0 {
76- cfg.MaxHistoryItems = dmenuMax
77+ // Override days if explicitly set via CLI flag
78+ if dmenuDays > 0 {
79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
7785 }
7886
7987 return cfg
@@ -131,7 +139,14 @@ func runDmenuPipe() error {
131139 ctx := context.Background()
132140 cfg := getDmenuConfig()
133141
134- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)
142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
135150 if err != nil {
136151 return fmt.Errorf("get history: %w", err)
137152 }
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/dumber/assets"14 "github.com/bnema/dumber/assets"
15 "github.com/bnema/dumber/internal/cli/model"15 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
16 domainurl "github.com/bnema/dumber/internal/domain/url"17 domainurl "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/infrastructure/favicon"19 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -22,7 +23,8 @@ import (
22 var (23 var (
23 dmenuInteractive bool24 dmenuInteractive bool
24 dmenuSelect bool25 dmenuSelect bool
25- dmenuMax int26+ dmenuDays int
27+ dmenuMostVisited bool
26 )28 )
27 29
28 var dmenuCmd = &cobra.Command{30 var dmenuCmd = &cobra.Command{
@@ -43,7 +45,8 @@ func init() {
43 45
44 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")46 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
45 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")47 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
47 }50 }
48 51
49 func runDmenu(_ *cobra.Command, _ []string) error {52 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
71 app := GetApp()74 app := GetApp()
72 cfg := app.Config.Dmenu75 cfg := app.Config.Dmenu
73 76
74- // Override max if explicitly set via CLI flag77+ // Override days if explicitly set via CLI flag
75- if dmenuMax > 0 {78+ if dmenuDays > 0 {
76- cfg.MaxHistoryItems = dmenuMax79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
77 }85 }
78 86
79 return cfg87 return cfg
@@ -131,7 +139,14 @@ func runDmenuPipe() error {
131 ctx := context.Background()139 ctx := context.Background()
132 cfg := getDmenuConfig()140 cfg := getDmenuConfig()
133 141
134- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
135 if err != nil {150 if err != nil {
136 return fmt.Errorf("get history: %w", err)151 return fmt.Errorf("get history: %w", err)
137 }152 }
M internal/domain/repository/history.go +6 -0
@@ -21,6 +21,12 @@ type HistoryRepository interface {
2121 // GetRecent retrieves recent history entries with pagination.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
26+
27+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
28+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
29+
2430 // IncrementVisitCount increments the visit count for a URL.
2531 IncrementVisitCount(ctx context.Context, url string) error
2632
@@ -21,6 +21,12 @@ type HistoryRepository interface {
21 // GetRecent retrieves recent history entries with pagination.21 // GetRecent retrieves recent history entries with pagination.
22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
23 23
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
26+
27+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
28+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
29+
24 // IncrementVisitCount increments the visit count for a URL.30 // IncrementVisitCount increments the visit count for a URL.
25 IncrementVisitCount(ctx context.Context, url string) error31 IncrementVisitCount(ctx context.Context, url string) error
26 32
M internal/domain/repository/mocks/mocks.go +136 -0
@@ -2395,6 +2395,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
23952395 return _c
23962396 }
23972397
2398+// GetMostVisited provides a mock function for the type MockHistoryRepository
2399+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2400+ ret := _mock.Called(ctx, days)
2401+
2402+ if len(ret) == 0 {
2403+ panic("no return value specified for GetMostVisited")
2404+ }
2405+
2406+ var r0 []*entity.HistoryEntry
2407+ var r1 error
2408+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2409+ return returnFunc(ctx, days)
2410+ }
2411+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2412+ r0 = returnFunc(ctx, days)
2413+ } else {
2414+ if ret.Get(0) != nil {
2415+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2416+ }
2417+ }
2418+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2419+ r1 = returnFunc(ctx, days)
2420+ } else {
2421+ r1 = ret.Error(1)
2422+ }
2423+ return r0, r1
2424+}
2425+
2426+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2427+type MockHistoryRepository_GetMostVisited_Call struct {
2428+ *mock.Call
2429+}
2430+
2431+// GetMostVisited is a helper method to define mock.On call
2432+// - ctx context.Context
2433+// - days int
2434+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2435+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2436+}
2437+
2438+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2439+ _c.Call.Run(func(args mock.Arguments) {
2440+ var arg0 context.Context
2441+ if args[0] != nil {
2442+ arg0 = args[0].(context.Context)
2443+ }
2444+ var arg1 int
2445+ if args[1] != nil {
2446+ arg1 = args[1].(int)
2447+ }
2448+ run(
2449+ arg0,
2450+ arg1,
2451+ )
2452+ })
2453+ return _c
2454+}
2455+
2456+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2457+ _c.Call.Return(historyEntrys, err)
2458+ return _c
2459+}
2460+
2461+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2462+ _c.Call.Return(run)
2463+ return _c
2464+}
2465+
23982466 // GetRecent provides a mock function for the type MockHistoryRepository
23992467 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
24002468 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2537,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
24692537 return _c
24702538 }
24712539
2540+// GetRecentSince provides a mock function for the type MockHistoryRepository
2541+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2542+ ret := _mock.Called(ctx, days)
2543+
2544+ if len(ret) == 0 {
2545+ panic("no return value specified for GetRecentSince")
2546+ }
2547+
2548+ var r0 []*entity.HistoryEntry
2549+ var r1 error
2550+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2551+ return returnFunc(ctx, days)
2552+ }
2553+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2554+ r0 = returnFunc(ctx, days)
2555+ } else {
2556+ if ret.Get(0) != nil {
2557+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2558+ }
2559+ }
2560+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2561+ r1 = returnFunc(ctx, days)
2562+ } else {
2563+ r1 = ret.Error(1)
2564+ }
2565+ return r0, r1
2566+}
2567+
2568+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2569+type MockHistoryRepository_GetRecentSince_Call struct {
2570+ *mock.Call
2571+}
2572+
2573+// GetRecentSince is a helper method to define mock.On call
2574+// - ctx context.Context
2575+// - days int
2576+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2577+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2581+ _c.Call.Run(func(args mock.Arguments) {
2582+ var arg0 context.Context
2583+ if args[0] != nil {
2584+ arg0 = args[0].(context.Context)
2585+ }
2586+ var arg1 int
2587+ if args[1] != nil {
2588+ arg1 = args[1].(int)
2589+ }
2590+ run(
2591+ arg0,
2592+ arg1,
2593+ )
2594+ })
2595+ return _c
2596+}
2597+
2598+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2599+ _c.Call.Return(historyEntrys, err)
2600+ return _c
2601+}
2602+
2603+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2604+ _c.Call.Return(run)
2605+ return _c
2606+}
2607+
24722608 // GetStats provides a mock function for the type MockHistoryRepository
24732609 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
24742610 ret := _mock.Called(ctx)
@@ -2395,6 +2395,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
2395 return _c2395 return _c
2396 }2396 }
2397 2397
2398+// GetMostVisited provides a mock function for the type MockHistoryRepository
2399+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2400+ ret := _mock.Called(ctx, days)
2401+
2402+ if len(ret) == 0 {
2403+ panic("no return value specified for GetMostVisited")
2404+ }
2405+
2406+ var r0 []*entity.HistoryEntry
2407+ var r1 error
2408+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2409+ return returnFunc(ctx, days)
2410+ }
2411+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2412+ r0 = returnFunc(ctx, days)
2413+ } else {
2414+ if ret.Get(0) != nil {
2415+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2416+ }
2417+ }
2418+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2419+ r1 = returnFunc(ctx, days)
2420+ } else {
2421+ r1 = ret.Error(1)
2422+ }
2423+ return r0, r1
2424+}
2425+
2426+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2427+type MockHistoryRepository_GetMostVisited_Call struct {
2428+ *mock.Call
2429+}
2430+
2431+// GetMostVisited is a helper method to define mock.On call
2432+// - ctx context.Context
2433+// - days int
2434+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2435+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2436+}
2437+
2438+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2439+ _c.Call.Run(func(args mock.Arguments) {
2440+ var arg0 context.Context
2441+ if args[0] != nil {
2442+ arg0 = args[0].(context.Context)
2443+ }
2444+ var arg1 int
2445+ if args[1] != nil {
2446+ arg1 = args[1].(int)
2447+ }
2448+ run(
2449+ arg0,
2450+ arg1,
2451+ )
2452+ })
2453+ return _c
2454+}
2455+
2456+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2457+ _c.Call.Return(historyEntrys, err)
2458+ return _c
2459+}
2460+
2461+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2462+ _c.Call.Return(run)
2463+ return _c
2464+}
2465+
2398 // GetRecent provides a mock function for the type MockHistoryRepository2466 // GetRecent provides a mock function for the type MockHistoryRepository
2399 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {2467 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
2400 ret := _mock.Called(ctx, limit, offset)2468 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2537,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
2469 return _c2537 return _c
2470 }2538 }
2471 2539
2540+// GetRecentSince provides a mock function for the type MockHistoryRepository
2541+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2542+ ret := _mock.Called(ctx, days)
2543+
2544+ if len(ret) == 0 {
2545+ panic("no return value specified for GetRecentSince")
2546+ }
2547+
2548+ var r0 []*entity.HistoryEntry
2549+ var r1 error
2550+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2551+ return returnFunc(ctx, days)
2552+ }
2553+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2554+ r0 = returnFunc(ctx, days)
2555+ } else {
2556+ if ret.Get(0) != nil {
2557+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2558+ }
2559+ }
2560+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2561+ r1 = returnFunc(ctx, days)
2562+ } else {
2563+ r1 = ret.Error(1)
2564+ }
2565+ return r0, r1
2566+}
2567+
2568+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2569+type MockHistoryRepository_GetRecentSince_Call struct {
2570+ *mock.Call
2571+}
2572+
2573+// GetRecentSince is a helper method to define mock.On call
2574+// - ctx context.Context
2575+// - days int
2576+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2577+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2581+ _c.Call.Run(func(args mock.Arguments) {
2582+ var arg0 context.Context
2583+ if args[0] != nil {
2584+ arg0 = args[0].(context.Context)
2585+ }
2586+ var arg1 int
2587+ if args[1] != nil {
2588+ arg1 = args[1].(int)
2589+ }
2590+ run(
2591+ arg0,
2592+ arg1,
2593+ )
2594+ })
2595+ return _c
2596+}
2597+
2598+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2599+ _c.Call.Return(historyEntrys, err)
2600+ return _c
2601+}
2602+
2603+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2604+ _c.Call.Return(run)
2605+ return _c
2606+}
2607+
2472 // GetStats provides a mock function for the type MockHistoryRepository2608 // GetStats provides a mock function for the type MockHistoryRepository
2473 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {2609 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
2474 ret := _mock.Called(ctx)2610 ret := _mock.Called(ctx)
M internal/infrastructure/config/defaults.go +2 -2
@@ -7,7 +7,7 @@ const (
77 defaultRetentionDays = 365 // 1 year
88
99 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items
10+ defaultMaxHistoryDays = 30 // days
1111
1212 // Logging defaults
1313 defaultMaxLogAgeDays = 7 // days
@@ -97,7 +97,7 @@ func DefaultConfig() *Config {
9797 SearchShortcuts: GetDefaultSearchShortcuts(),
9898 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
9999 Dmenu: DmenuConfig{
100- MaxHistoryItems: defaultMaxHistoryItems,
100+ MaxHistoryDays: defaultMaxHistoryDays,
101101 ShowVisitCount: true,
102102 ShowLastVisited: true,
103103 HistoryPrefix: "🕒",
@@ -7,7 +7,7 @@ const (
7 defaultRetentionDays = 365 // 1 year7 defaultRetentionDays = 365 // 1 year
8 8
9 // Dmenu defaults9 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items10+ defaultMaxHistoryDays = 30 // days
11 11
12 // Logging defaults12 // Logging defaults
13 defaultMaxLogAgeDays = 7 // days13 defaultMaxLogAgeDays = 7 // days
@@ -97,7 +97,7 @@ func DefaultConfig() *Config {
97 SearchShortcuts: GetDefaultSearchShortcuts(),97 SearchShortcuts: GetDefaultSearchShortcuts(),
98 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",98 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
99 Dmenu: DmenuConfig{99 Dmenu: DmenuConfig{
100- MaxHistoryItems: defaultMaxHistoryItems,100+ MaxHistoryDays: defaultMaxHistoryDays,
101 ShowVisitCount: true,101 ShowVisitCount: true,
102 ShowLastVisited: true,102 ShowLastVisited: true,
103 HistoryPrefix: "🕒",103 HistoryPrefix: "🕒",
M internal/infrastructure/config/loader.go +1 -1
@@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
348348 }
349349
350350 func (m *Manager) setDmenuDefaults(defaults *Config) {
351- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
352352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
353353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
354354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
@@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
348 }348 }
349 349
350 func (m *Manager) setDmenuDefaults(defaults *Config) {350 func (m *Manager) setDmenuDefaults(defaults *Config) {
351- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
M internal/infrastructure/config/schema.go +1 -1
@@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string {
286286
287287 // DmenuConfig holds dmenu/rofi integration configuration.
288288 type DmenuConfig struct {
289- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
289+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
290290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
291291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
292292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
@@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string {
286 286
287 // DmenuConfig holds dmenu/rofi integration configuration.287 // DmenuConfig holds dmenu/rofi integration configuration.
288 type DmenuConfig struct {288 type DmenuConfig struct {
289- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`289+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
M internal/infrastructure/config/schema_provider.go +3 -3
@@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
264264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265265 return []entity.ConfigKeyInfo{
266266 {
267- Key: "dmenu.max_history_items",
267+ Key: "dmenu.max_history_days",
268268 Type: "int",
269- Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),
270- Description: "Maximum history items shown in dmenu",
269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays),
270+ Description: "Number of days of history to show in dmenu (0 = all)",
271271 Range: ">=0",
272272 Section: SectionDmenu,
273273 },
@@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265 return []entity.ConfigKeyInfo{265 return []entity.ConfigKeyInfo{
266 {266 {
267- Key: "dmenu.max_history_items",267+ Key: "dmenu.max_history_days",
268 Type: "int",268 Type: "int",
269- Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays),
270- Description: "Maximum history items shown in dmenu",270+ Description: "Number of days of history to show in dmenu (0 = all)",
271 Range: ">=0",271 Range: ">=0",
272 Section: SectionDmenu,272 Section: SectionDmenu,
273 },273 },
M internal/infrastructure/config/validation.go +2 -2
@@ -53,8 +53,8 @@ func validateHistory(config *Config) []string {
5353 }
5454
5555 func validateDmenu(config *Config) []string {
56- if config.Dmenu.MaxHistoryItems < 0 {
57- return []string{"dmenu.max_history_items must be non-negative"}
56+ if config.Dmenu.MaxHistoryDays < 0 {
57+ return []string{"dmenu.max_history_days must be non-negative"}
5858 }
5959 return nil
6060 }
@@ -53,8 +53,8 @@ func validateHistory(config *Config) []string {
53 }53 }
54 54
55 func validateDmenu(config *Config) []string {55 func validateDmenu(config *Config) []string {
56- if config.Dmenu.MaxHistoryItems < 0 {56+ if config.Dmenu.MaxHistoryDays < 0 {
57- return []string{"dmenu.max_history_items must be non-negative"}57+ return []string{"dmenu.max_history_days must be non-negative"}
58 }58 }
59 return nil59 return nil
60 }60 }
M internal/infrastructure/persistence/sqlite/history_repo.go +31 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "errors"
7+ "fmt"
78 "time"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -87,6 +88,36 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
8788 return entries, nil
8889 }
8990
91+func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
92+ // Format: "-N days" for SQLite datetime modifier
93+ daysModifier := fmt.Sprintf("-%d days", days)
94+ rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ entries := make([]*entity.HistoryEntry, len(rows))
100+ for i := range rows {
101+ entries[i] = historyFromRow(rows[i])
102+ }
103+ return entries, nil
104+}
105+
106+func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ // Format: "-N days" for SQLite datetime modifier
108+ daysModifier := fmt.Sprintf("-%d days", days)
109+ rows, err := r.queries.GetMostVisited(ctx, daysModifier)
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ entries := make([]*entity.HistoryEntry, len(rows))
115+ for i := range rows {
116+ entries[i] = historyFromRow(rows[i])
117+ }
118+ return entries, nil
119+}
120+
90121 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
91122 return r.queries.IncrementVisitCount(ctx, url)
92123 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "errors"6 "errors"
7+ "fmt"
7 "time"8 "time"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -87,6 +88,36 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
87 return entries, nil88 return entries, nil
88 }89 }
89 90
91+func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
92+ // Format: "-N days" for SQLite datetime modifier
93+ daysModifier := fmt.Sprintf("-%d days", days)
94+ rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ entries := make([]*entity.HistoryEntry, len(rows))
100+ for i := range rows {
101+ entries[i] = historyFromRow(rows[i])
102+ }
103+ return entries, nil
104+}
105+
106+func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ // Format: "-N days" for SQLite datetime modifier
108+ daysModifier := fmt.Sprintf("-%d days", days)
109+ rows, err := r.queries.GetMostVisited(ctx, daysModifier)
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ entries := make([]*entity.HistoryEntry, len(rows))
115+ for i := range rows {
116+ entries[i] = historyFromRow(rows[i])
117+ }
118+ return entries, nil
119+}
120+
90 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {121 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
91 return r.queries.IncrementVisitCount(ctx, url)122 return r.queries.IncrementVisitCount(ctx, url)
92 }123 }
A internal/infrastructure/persistence/sqlite/history_repo_test.go +226 -0
@@ -0,0 +1,226 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func historyTestCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestHistoryRepository_GetRecentSince(t *testing.T) {
22+ ctx := historyTestCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewHistoryRepository(db)
30+
31+ // Save some history entries
32+ entries := []*entity.HistoryEntry{
33+ {URL: "https://example.com", Title: "Example"},
34+ {URL: "https://github.com", Title: "GitHub"},
35+ {URL: "https://google.com", Title: "Google"},
36+ }
37+
38+ for _, e := range entries {
39+ require.NoError(t, repo.Save(ctx, e))
40+ }
41+
42+ // Get recent since 30 days (all should be included)
43+ results, err := repo.GetRecentSince(ctx, 30)
44+ require.NoError(t, err)
45+ require.Len(t, results, 3)
46+
47+ // Results should be sorted by last_visited DESC
48+ // Since they were all just inserted, order may vary
49+ urls := make(map[string]bool)
50+ for _, r := range results {
51+ urls[r.URL] = true
52+ }
53+ assert.True(t, urls["https://example.com"])
54+ assert.True(t, urls["https://github.com"])
55+ assert.True(t, urls["https://google.com"])
56+}
57+
58+func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59+ ctx := historyTestCtx()
60+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
61+
62+ db, err := sqlite.NewConnection(ctx, dbPath)
63+ require.NoError(t, err)
64+ t.Cleanup(func() { _ = db.Close() })
65+
66+ repo := sqlite.NewHistoryRepository(db)
67+
68+ // Get recent with no data
69+ results, err := repo.GetRecentSince(ctx, 30)
70+ require.NoError(t, err)
71+ assert.Empty(t, results)
72+}
73+
74+func TestHistoryRepository_GetMostVisited(t *testing.T) {
75+ ctx := historyTestCtx()
76+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
77+
78+ db, err := sqlite.NewConnection(ctx, dbPath)
79+ require.NoError(t, err)
80+ t.Cleanup(func() { _ = db.Close() })
81+
82+ repo := sqlite.NewHistoryRepository(db)
83+
84+ // Save entries with different visit counts
85+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87+ entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88+
89+ require.NoError(t, repo.Save(ctx, entry1))
90+ require.NoError(t, repo.Save(ctx, entry2))
91+ require.NoError(t, repo.Save(ctx, entry3))
92+
93+ // Increment visit counts to create different popularities
94+ // github: 5 visits, google: 3 visits, example: 1 visit
95+ for i := 0; i < 4; i++ {
96+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97+ }
98+ for i := 0; i < 2; i++ {
99+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100+ }
101+
102+ // Get most visited
103+ results, err := repo.GetMostVisited(ctx, 30)
104+ require.NoError(t, err)
105+ require.Len(t, results, 3)
106+
107+ // First result should be github (5 visits)
108+ assert.Equal(t, "https://github.com", results[0].URL)
109+ assert.Equal(t, int64(5), results[0].VisitCount)
110+
111+ // Second should be google (3 visits)
112+ assert.Equal(t, "https://google.com", results[1].URL)
113+ assert.Equal(t, int64(3), results[1].VisitCount)
114+
115+ // Third should be example (1 visit)
116+ assert.Equal(t, "https://example.com", results[2].URL)
117+ assert.Equal(t, int64(1), results[2].VisitCount)
118+}
119+
120+func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121+ ctx := historyTestCtx()
122+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
123+
124+ db, err := sqlite.NewConnection(ctx, dbPath)
125+ require.NoError(t, err)
126+ t.Cleanup(func() { _ = db.Close() })
127+
128+ repo := sqlite.NewHistoryRepository(db)
129+
130+ // Get most visited with no data
131+ results, err := repo.GetMostVisited(ctx, 30)
132+ require.NoError(t, err)
133+ assert.Empty(t, results)
134+}
135+
136+func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137+ ctx := historyTestCtx()
138+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
139+
140+ db, err := sqlite.NewConnection(ctx, dbPath)
141+ require.NoError(t, err)
142+ t.Cleanup(func() { _ = db.Close() })
143+
144+ repo := sqlite.NewHistoryRepository(db)
145+
146+ // Save an entry
147+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148+ require.NoError(t, repo.Save(ctx, entry))
149+
150+ // Query with 1 day filter - should include recently added entry
151+ results, err := repo.GetRecentSince(ctx, 1)
152+ require.NoError(t, err)
153+ require.Len(t, results, 1)
154+
155+ // Query with very large days filter - should still work
156+ results, err = repo.GetRecentSince(ctx, 365)
157+ require.NoError(t, err)
158+ require.Len(t, results, 1)
159+}
160+
161+func TestHistoryRepository_CRUD(t *testing.T) {
162+ ctx := historyTestCtx()
163+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
164+
165+ db, err := sqlite.NewConnection(ctx, dbPath)
166+ require.NoError(t, err)
167+ t.Cleanup(func() { _ = db.Close() })
168+
169+ repo := sqlite.NewHistoryRepository(db)
170+
171+ // Create
172+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173+ require.NoError(t, repo.Save(ctx, entry))
174+
175+ // Read
176+ found, err := repo.FindByURL(ctx, "https://example.com")
177+ require.NoError(t, err)
178+ require.NotNil(t, found)
179+ assert.Equal(t, "https://example.com", found.URL)
180+ assert.Equal(t, "Example", found.Title)
181+ assert.Equal(t, int64(1), found.VisitCount)
182+
183+ // Update (save again increments visit count)
184+ require.NoError(t, repo.Save(ctx, entry))
185+ found2, err := repo.FindByURL(ctx, "https://example.com")
186+ require.NoError(t, err)
187+ assert.Equal(t, int64(2), found2.VisitCount)
188+
189+ // Get recent
190+ recent, err := repo.GetRecent(ctx, 10, 0)
191+ require.NoError(t, err)
192+ require.Len(t, recent, 1)
193+
194+ // Delete
195+ require.NoError(t, repo.Delete(ctx, found.ID))
196+
197+ // Verify deleted
198+ deleted, err := repo.FindByURL(ctx, "https://example.com")
199+ require.NoError(t, err)
200+ assert.Nil(t, deleted)
201+}
202+
203+// Ensure the test uses an isolated timestamp for time-sensitive queries
204+func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205+ ctx := historyTestCtx()
206+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
207+
208+ db, err := sqlite.NewConnection(ctx, dbPath)
209+ require.NoError(t, err)
210+ t.Cleanup(func() { _ = db.Close() })
211+
212+ repo := sqlite.NewHistoryRepository(db)
213+
214+ // Save entry
215+ entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216+ require.NoError(t, repo.Save(ctx, entry))
217+
218+ // Verify entry was saved with recent timestamp
219+ found, err := repo.FindByURL(ctx, "https://test.com")
220+ require.NoError(t, err)
221+ require.NotNil(t, found)
222+
223+ // LastVisited should be within the last minute
224+ timeDiff := time.Since(found.LastVisited)
225+ assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226+}
@@ -0,0 +1,226 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func historyTestCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestHistoryRepository_GetRecentSince(t *testing.T) {
22+ ctx := historyTestCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewHistoryRepository(db)
30+
31+ // Save some history entries
32+ entries := []*entity.HistoryEntry{
33+ {URL: "https://example.com", Title: "Example"},
34+ {URL: "https://github.com", Title: "GitHub"},
35+ {URL: "https://google.com", Title: "Google"},
36+ }
37+
38+ for _, e := range entries {
39+ require.NoError(t, repo.Save(ctx, e))
40+ }
41+
42+ // Get recent since 30 days (all should be included)
43+ results, err := repo.GetRecentSince(ctx, 30)
44+ require.NoError(t, err)
45+ require.Len(t, results, 3)
46+
47+ // Results should be sorted by last_visited DESC
48+ // Since they were all just inserted, order may vary
49+ urls := make(map[string]bool)
50+ for _, r := range results {
51+ urls[r.URL] = true
52+ }
53+ assert.True(t, urls["https://example.com"])
54+ assert.True(t, urls["https://github.com"])
55+ assert.True(t, urls["https://google.com"])
56+}
57+
58+func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59+ ctx := historyTestCtx()
60+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
61+
62+ db, err := sqlite.NewConnection(ctx, dbPath)
63+ require.NoError(t, err)
64+ t.Cleanup(func() { _ = db.Close() })
65+
66+ repo := sqlite.NewHistoryRepository(db)
67+
68+ // Get recent with no data
69+ results, err := repo.GetRecentSince(ctx, 30)
70+ require.NoError(t, err)
71+ assert.Empty(t, results)
72+}
73+
74+func TestHistoryRepository_GetMostVisited(t *testing.T) {
75+ ctx := historyTestCtx()
76+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
77+
78+ db, err := sqlite.NewConnection(ctx, dbPath)
79+ require.NoError(t, err)
80+ t.Cleanup(func() { _ = db.Close() })
81+
82+ repo := sqlite.NewHistoryRepository(db)
83+
84+ // Save entries with different visit counts
85+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87+ entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88+
89+ require.NoError(t, repo.Save(ctx, entry1))
90+ require.NoError(t, repo.Save(ctx, entry2))
91+ require.NoError(t, repo.Save(ctx, entry3))
92+
93+ // Increment visit counts to create different popularities
94+ // github: 5 visits, google: 3 visits, example: 1 visit
95+ for i := 0; i < 4; i++ {
96+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97+ }
98+ for i := 0; i < 2; i++ {
99+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100+ }
101+
102+ // Get most visited
103+ results, err := repo.GetMostVisited(ctx, 30)
104+ require.NoError(t, err)
105+ require.Len(t, results, 3)
106+
107+ // First result should be github (5 visits)
108+ assert.Equal(t, "https://github.com", results[0].URL)
109+ assert.Equal(t, int64(5), results[0].VisitCount)
110+
111+ // Second should be google (3 visits)
112+ assert.Equal(t, "https://google.com", results[1].URL)
113+ assert.Equal(t, int64(3), results[1].VisitCount)
114+
115+ // Third should be example (1 visit)
116+ assert.Equal(t, "https://example.com", results[2].URL)
117+ assert.Equal(t, int64(1), results[2].VisitCount)
118+}
119+
120+func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121+ ctx := historyTestCtx()
122+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
123+
124+ db, err := sqlite.NewConnection(ctx, dbPath)
125+ require.NoError(t, err)
126+ t.Cleanup(func() { _ = db.Close() })
127+
128+ repo := sqlite.NewHistoryRepository(db)
129+
130+ // Get most visited with no data
131+ results, err := repo.GetMostVisited(ctx, 30)
132+ require.NoError(t, err)
133+ assert.Empty(t, results)
134+}
135+
136+func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137+ ctx := historyTestCtx()
138+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
139+
140+ db, err := sqlite.NewConnection(ctx, dbPath)
141+ require.NoError(t, err)
142+ t.Cleanup(func() { _ = db.Close() })
143+
144+ repo := sqlite.NewHistoryRepository(db)
145+
146+ // Save an entry
147+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148+ require.NoError(t, repo.Save(ctx, entry))
149+
150+ // Query with 1 day filter - should include recently added entry
151+ results, err := repo.GetRecentSince(ctx, 1)
152+ require.NoError(t, err)
153+ require.Len(t, results, 1)
154+
155+ // Query with very large days filter - should still work
156+ results, err = repo.GetRecentSince(ctx, 365)
157+ require.NoError(t, err)
158+ require.Len(t, results, 1)
159+}
160+
161+func TestHistoryRepository_CRUD(t *testing.T) {
162+ ctx := historyTestCtx()
163+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
164+
165+ db, err := sqlite.NewConnection(ctx, dbPath)
166+ require.NoError(t, err)
167+ t.Cleanup(func() { _ = db.Close() })
168+
169+ repo := sqlite.NewHistoryRepository(db)
170+
171+ // Create
172+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173+ require.NoError(t, repo.Save(ctx, entry))
174+
175+ // Read
176+ found, err := repo.FindByURL(ctx, "https://example.com")
177+ require.NoError(t, err)
178+ require.NotNil(t, found)
179+ assert.Equal(t, "https://example.com", found.URL)
180+ assert.Equal(t, "Example", found.Title)
181+ assert.Equal(t, int64(1), found.VisitCount)
182+
183+ // Update (save again increments visit count)
184+ require.NoError(t, repo.Save(ctx, entry))
185+ found2, err := repo.FindByURL(ctx, "https://example.com")
186+ require.NoError(t, err)
187+ assert.Equal(t, int64(2), found2.VisitCount)
188+
189+ // Get recent
190+ recent, err := repo.GetRecent(ctx, 10, 0)
191+ require.NoError(t, err)
192+ require.Len(t, recent, 1)
193+
194+ // Delete
195+ require.NoError(t, repo.Delete(ctx, found.ID))
196+
197+ // Verify deleted
198+ deleted, err := repo.FindByURL(ctx, "https://example.com")
199+ require.NoError(t, err)
200+ assert.Nil(t, deleted)
201+}
202+
203+// Ensure the test uses an isolated timestamp for time-sensitive queries
204+func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205+ ctx := historyTestCtx()
206+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
207+
208+ db, err := sqlite.NewConnection(ctx, dbPath)
209+ require.NoError(t, err)
210+ t.Cleanup(func() { _ = db.Close() })
211+
212+ repo := sqlite.NewHistoryRepository(db)
213+
214+ // Save entry
215+ entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216+ require.NoError(t, repo.Save(ctx, entry))
217+
218+ // Verify entry was saved with recent timestamp
219+ found, err := repo.FindByURL(ctx, "https://test.com")
220+ require.NoError(t, err)
221+ require.NotNil(t, found)
222+
223+ // LastVisited should be within the last minute
224+ timeDiff := time.Since(found.LastVisited)
225+ assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +14 -0
@@ -79,6 +79,20 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
7979 return r.repo.GetRecent(ctx, limit, offset)
8080 }
8181
82+func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
83+ if err := r.init(ctx); err != nil {
84+ return nil, err
85+ }
86+ return r.repo.GetRecentSince(ctx, days)
87+}
88+
89+func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
90+ if err := r.init(ctx); err != nil {
91+ return nil, err
92+ }
93+ return r.repo.GetMostVisited(ctx, days)
94+}
95+
8296 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
8397 if err := r.init(ctx); err != nil {
8498 return err
@@ -79,6 +79,20 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
79 return r.repo.GetRecent(ctx, limit, offset)79 return r.repo.GetRecent(ctx, limit, offset)
80 }80 }
81 81
82+func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
83+ if err := r.init(ctx); err != nil {
84+ return nil, err
85+ }
86+ return r.repo.GetRecentSince(ctx, days)
87+}
88+
89+func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
90+ if err := r.init(ctx); err != nil {
91+ return nil, err
92+ }
93+ return r.repo.GetMostVisited(ctx, days)
94+}
95+
82 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {96 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83 if err := r.init(ctx); err != nil {97 if err := r.init(ctx); err != nil {
84 return err98 return err
M internal/infrastructure/persistence/sqlite/queries/history.sql +10 -0
@@ -56,3 +56,13 @@ JOIN history h ON fts.rowid = h.id
5656 WHERE fts.url MATCH ?
5757 ORDER BY bm25(history_fts)
5858 LIMIT ?;
59+
60+-- name: GetRecentHistorySince :many
61+SELECT * FROM history
62+WHERE last_visited >= datetime('now', ?)
63+ORDER BY last_visited DESC;
64+
65+-- name: GetMostVisited :many
66+SELECT * FROM history
67+WHERE last_visited >= datetime('now', ?)
68+ORDER BY visit_count DESC, last_visited DESC;
@@ -56,3 +56,13 @@ JOIN history h ON fts.rowid = h.id
56 WHERE fts.url MATCH ?56 WHERE fts.url MATCH ?
57 ORDER BY bm25(history_fts)57 ORDER BY bm25(history_fts)
58 LIMIT ?;58 LIMIT ?;
59+
60+-- name: GetRecentHistorySince :many
61+SELECT * FROM history
62+WHERE last_visited >= datetime('now', ?)
63+ORDER BY last_visited DESC;
64+
65+-- name: GetMostVisited :many
66+SELECT * FROM history
67+WHERE last_visited >= datetime('now', ?)
68+ORDER BY visit_count DESC, last_visited DESC;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +74 -0
@@ -198,6 +198,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
198198 return items, nil
199199 }
200200
201+const GetMostVisited = `-- name: GetMostVisited :many
202+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
203+WHERE last_visited >= datetime('now', ?)
204+ORDER BY visit_count DESC, last_visited DESC
205+`
206+
207+func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) {
208+ rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime)
209+ if err != nil {
210+ return nil, err
211+ }
212+ defer rows.Close()
213+ items := []History{}
214+ for rows.Next() {
215+ var i History
216+ if err := rows.Scan(
217+ &i.ID,
218+ &i.Url,
219+ &i.Title,
220+ &i.FaviconUrl,
221+ &i.VisitCount,
222+ &i.LastVisited,
223+ &i.CreatedAt,
224+ ); err != nil {
225+ return nil, err
226+ }
227+ items = append(items, i)
228+ }
229+ if err := rows.Close(); err != nil {
230+ return nil, err
231+ }
232+ if err := rows.Err(); err != nil {
233+ return nil, err
234+ }
235+ return items, nil
236+}
237+
201238 const GetRecentHistory = `-- name: GetRecentHistory :many
202239 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
203240 `
@@ -238,6 +275,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
238275 return items, nil
239276 }
240277
278+const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
279+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
280+WHERE last_visited >= datetime('now', ?)
281+ORDER BY last_visited DESC
282+`
283+
284+func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) {
285+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime)
286+ if err != nil {
287+ return nil, err
288+ }
289+ defer rows.Close()
290+ items := []History{}
291+ for rows.Next() {
292+ var i History
293+ if err := rows.Scan(
294+ &i.ID,
295+ &i.Url,
296+ &i.Title,
297+ &i.FaviconUrl,
298+ &i.VisitCount,
299+ &i.LastVisited,
300+ &i.CreatedAt,
301+ ); err != nil {
302+ return nil, err
303+ }
304+ items = append(items, i)
305+ }
306+ if err := rows.Close(); err != nil {
307+ return nil, err
308+ }
309+ if err := rows.Err(); err != nil {
310+ return nil, err
311+ }
312+ return items, nil
313+}
314+
241315 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
242316 UPDATE history
243317 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
@@ -198,6 +198,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
198 return items, nil198 return items, nil
199 }199 }
200 200
201+const GetMostVisited = `-- name: GetMostVisited :many
202+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
203+WHERE last_visited >= datetime('now', ?)
204+ORDER BY visit_count DESC, last_visited DESC
205+`
206+
207+func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) {
208+ rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime)
209+ if err != nil {
210+ return nil, err
211+ }
212+ defer rows.Close()
213+ items := []History{}
214+ for rows.Next() {
215+ var i History
216+ if err := rows.Scan(
217+ &i.ID,
218+ &i.Url,
219+ &i.Title,
220+ &i.FaviconUrl,
221+ &i.VisitCount,
222+ &i.LastVisited,
223+ &i.CreatedAt,
224+ ); err != nil {
225+ return nil, err
226+ }
227+ items = append(items, i)
228+ }
229+ if err := rows.Close(); err != nil {
230+ return nil, err
231+ }
232+ if err := rows.Err(); err != nil {
233+ return nil, err
234+ }
235+ return items, nil
236+}
237+
201 const GetRecentHistory = `-- name: GetRecentHistory :many238 const GetRecentHistory = `-- name: GetRecentHistory :many
202 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?239 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
203 `240 `
@@ -238,6 +275,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
238 return items, nil275 return items, nil
239 }276 }
240 277
278+const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
279+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
280+WHERE last_visited >= datetime('now', ?)
281+ORDER BY last_visited DESC
282+`
283+
284+func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) {
285+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime)
286+ if err != nil {
287+ return nil, err
288+ }
289+ defer rows.Close()
290+ items := []History{}
291+ for rows.Next() {
292+ var i History
293+ if err := rows.Scan(
294+ &i.ID,
295+ &i.Url,
296+ &i.Title,
297+ &i.FaviconUrl,
298+ &i.VisitCount,
299+ &i.LastVisited,
300+ &i.CreatedAt,
301+ ); err != nil {
302+ return nil, err
303+ }
304+ items = append(items, i)
305+ }
306+ if err := rows.Close(); err != nil {
307+ return nil, err
308+ }
309+ if err := rows.Err(); err != nil {
310+ return nil, err
311+ }
312+ return items, nil
313+}
314+
241 const IncrementVisitCount = `-- name: IncrementVisitCount :exec315 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
242 UPDATE history316 UPDATE history
243 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP317 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -0
@@ -47,7 +47,9 @@ type Querier interface {
4747 GetHistoryByURL(ctx context.Context, url string) (History, error)
4848 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
4949 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
50+ GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
5051 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
52+ GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
5153 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
5254 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
5355 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -47,7 +47,9 @@ type Querier interface {
47 GetHistoryByURL(ctx context.Context, url string) (History, error)47 GetHistoryByURL(ctx context.Context, url string) (History, error)
48 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)48 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
49 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)49 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
50+ GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
50 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)51 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
52+ GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
51 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)53 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
52 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)54 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
53 GetSessionByID(ctx context.Context, id string) (Session, error)55 GetSessionByID(ctx context.Context, id string) (Session, 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