detached 4f3ae02

feat(systemviews): enrich history overview

commit
4f3ae02043482b4483aa16cac716051efeb52a97
author
brice · · 2026-04-24 01:56 UTC
parent
c23bc35
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +315 -25
M assets/systemviews/systemviews.css +84 -0
@@ -121,6 +121,90 @@ body {
121121 color: var(--sv-muted, #9aa3b2);
122122 }
123123
124+.sv-stat-grid {
125+ display: grid;
126+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
127+ gap: 0.75rem;
128+ margin-top: 0.75rem;
129+}
130+
131+.sv-stat-card {
132+ display: grid;
133+ gap: 0.15rem;
134+ padding: 0.75rem;
135+ border: 1px solid var(--sv-border, #2a313d);
136+ border-radius: 0.65rem;
137+ background: var(--sv-surface-variant, #1d2129);
138+}
139+
140+.sv-stat-value {
141+ color: var(--sv-accent, #8ab4ff);
142+ font-size: 1.35rem;
143+ font-weight: 700;
144+}
145+
146+.sv-stat-label {
147+ color: var(--sv-muted, #9aa3b2);
148+ font-size: 12px;
149+ text-transform: uppercase;
150+ letter-spacing: 0.08em;
151+}
152+
153+.sv-domain-list {
154+ display: flex;
155+ flex-wrap: wrap;
156+ gap: 0.5rem;
157+ margin-top: 0.75rem;
158+}
159+
160+.sv-domain-chip {
161+ display: inline-grid;
162+ gap: 0.15rem;
163+ padding: 0.45rem 0.65rem;
164+ border: 1px solid var(--sv-border, #2a313d);
165+ border-radius: 999px;
166+ background: var(--sv-surface-variant, #1d2129);
167+}
168+
169+.sv-domain-meta,
170+.sv-history-meta,
171+.sv-history-url {
172+ color: var(--sv-muted, #9aa3b2);
173+ font-size: 12px;
174+}
175+
176+.sv-timeline-group + .sv-timeline-group {
177+ margin-top: 1rem;
178+}
179+
180+.sv-timeline-group h3 {
181+ margin: 0 0 0.5rem;
182+ color: var(--sv-muted, #9aa3b2);
183+ font-size: 12px;
184+ letter-spacing: 0.08em;
185+ text-transform: uppercase;
186+}
187+
188+.sv-history-item {
189+ display: grid;
190+ grid-template-columns: minmax(0, 1fr) auto;
191+ gap: 1rem;
192+ align-items: start;
193+}
194+
195+.sv-history-main {
196+ min-width: 0;
197+}
198+
199+.sv-history-url,
200+.sv-history-meta {
201+ margin: 0.25rem 0 0;
202+}
203+
204+.sv-history-url {
205+ overflow-wrap: anywhere;
206+}
207+
124208 .sv-error {
125209 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
126210 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
@@ -121,6 +121,90 @@ body {
121 color: var(--sv-muted, #9aa3b2);121 color: var(--sv-muted, #9aa3b2);
122 }122 }
123 123
124+.sv-stat-grid {
125+ display: grid;
126+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
127+ gap: 0.75rem;
128+ margin-top: 0.75rem;
129+}
130+
131+.sv-stat-card {
132+ display: grid;
133+ gap: 0.15rem;
134+ padding: 0.75rem;
135+ border: 1px solid var(--sv-border, #2a313d);
136+ border-radius: 0.65rem;
137+ background: var(--sv-surface-variant, #1d2129);
138+}
139+
140+.sv-stat-value {
141+ color: var(--sv-accent, #8ab4ff);
142+ font-size: 1.35rem;
143+ font-weight: 700;
144+}
145+
146+.sv-stat-label {
147+ color: var(--sv-muted, #9aa3b2);
148+ font-size: 12px;
149+ text-transform: uppercase;
150+ letter-spacing: 0.08em;
151+}
152+
153+.sv-domain-list {
154+ display: flex;
155+ flex-wrap: wrap;
156+ gap: 0.5rem;
157+ margin-top: 0.75rem;
158+}
159+
160+.sv-domain-chip {
161+ display: inline-grid;
162+ gap: 0.15rem;
163+ padding: 0.45rem 0.65rem;
164+ border: 1px solid var(--sv-border, #2a313d);
165+ border-radius: 999px;
166+ background: var(--sv-surface-variant, #1d2129);
167+}
168+
169+.sv-domain-meta,
170+.sv-history-meta,
171+.sv-history-url {
172+ color: var(--sv-muted, #9aa3b2);
173+ font-size: 12px;
174+}
175+
176+.sv-timeline-group + .sv-timeline-group {
177+ margin-top: 1rem;
178+}
179+
180+.sv-timeline-group h3 {
181+ margin: 0 0 0.5rem;
182+ color: var(--sv-muted, #9aa3b2);
183+ font-size: 12px;
184+ letter-spacing: 0.08em;
185+ text-transform: uppercase;
186+}
187+
188+.sv-history-item {
189+ display: grid;
190+ grid-template-columns: minmax(0, 1fr) auto;
191+ gap: 1rem;
192+ align-items: start;
193+}
194+
195+.sv-history-main {
196+ min-width: 0;
197+}
198+
199+.sv-history-url,
200+.sv-history-meta {
201+ margin: 0.25rem 0 0;
202+}
203+
204+.sv-history-url {
205+ overflow-wrap: anywhere;
206+}
207+
124 .sv-error {208 .sv-error {
125 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));209 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
126 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));210 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
M internal/infrastructure/cef/popup_opener_bridge_test.go +1 -1
@@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
3737 }
3838
3939 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
40- require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))
40+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://history"))
4141 }
4242
4343 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
@@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
37 }37 }
38 38
39 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {39 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
40- require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))40+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://history"))
41 }41 }
42 42
43 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {43 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
M internal/ui/systemviews/app.go +29 -11
@@ -17,16 +17,18 @@ type Dependencies struct {
1717 }
1818
1919 type App struct {
20- deps Dependencies
21- currentRoute Route
22- shellTheme shellTheme
23- historyEntries []*entity.HistoryEntry
24- favorites []*entity.Favorite
25- folders []*entity.Folder
26- tags []*entity.Tag
27- config *port.SystemviewConfigPayload
28- keybindings any
29- renderedHTML string
20+ deps Dependencies
21+ currentRoute Route
22+ shellTheme shellTheme
23+ historyEntries []*entity.HistoryEntry
24+ historyAnalytics *entity.HistoryAnalytics
25+ historyDomainStats []*entity.DomainStat
26+ favorites []*entity.Favorite
27+ folders []*entity.Folder
28+ tags []*entity.Tag
29+ config *port.SystemviewConfigPayload
30+ keybindings any
31+ renderedHTML string
3032 }
3133
3234 const historyTimelineLimit = 25
@@ -124,14 +126,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
124126 if err != nil {
125127 return err
126128 }
129+ analytics, analyticsErr := a.deps.History.Analytics(ctx)
130+ if analyticsErr != nil {
131+ analytics = nil
132+ }
133+ domains, domainsErr := a.deps.History.DomainStats(ctx, 10)
134+ if domainsErr != nil {
135+ domains = nil
136+ }
127137 a.favorites = nil
128138 a.folders = nil
129139 a.tags = nil
130140 a.historyEntries = entries
141+ a.historyAnalytics = analytics
142+ a.historyDomainStats = domains
131143 a.renderedHTML = renderAppFrame(renderedPage{
132144 route: RouteHistory,
133145 subtitle: "Recent visits",
134- body: historyHTML(entries),
146+ body: historyHTML(historyRenderData{
147+ Entries: entries,
148+ Analytics: analytics,
149+ Domains: domains,
150+ }),
135151 }, a.shellTheme)
136152 return nil
137153 }
@@ -224,6 +240,8 @@ func (a *App) loadShellTheme(ctx context.Context) {
224240
225241 func (a *App) resetRouteState() {
226242 a.historyEntries = nil
243+ a.historyAnalytics = nil
244+ a.historyDomainStats = nil
227245 a.favorites = nil
228246 a.folders = nil
229247 a.tags = nil
@@ -17,16 +17,18 @@ type Dependencies struct {
17 }17 }
18 18
19 type App struct {19 type App struct {
20- deps Dependencies20+ deps Dependencies
21- currentRoute Route21+ currentRoute Route
22- shellTheme shellTheme22+ shellTheme shellTheme
23- historyEntries []*entity.HistoryEntry23+ historyEntries []*entity.HistoryEntry
24- favorites []*entity.Favorite24+ historyAnalytics *entity.HistoryAnalytics
25- folders []*entity.Folder25+ historyDomainStats []*entity.DomainStat
26- tags []*entity.Tag26+ favorites []*entity.Favorite
27- config *port.SystemviewConfigPayload27+ folders []*entity.Folder
28- keybindings any28+ tags []*entity.Tag
29- renderedHTML string29+ config *port.SystemviewConfigPayload
30+ keybindings any
31+ renderedHTML string
30 }32 }
31 33
32 const historyTimelineLimit = 2534 const historyTimelineLimit = 25
@@ -124,14 +126,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
124 if err != nil {126 if err != nil {
125 return err127 return err
126 }128 }
129+ analytics, analyticsErr := a.deps.History.Analytics(ctx)
130+ if analyticsErr != nil {
131+ analytics = nil
132+ }
133+ domains, domainsErr := a.deps.History.DomainStats(ctx, 10)
134+ if domainsErr != nil {
135+ domains = nil
136+ }
127 a.favorites = nil137 a.favorites = nil
128 a.folders = nil138 a.folders = nil
129 a.tags = nil139 a.tags = nil
130 a.historyEntries = entries140 a.historyEntries = entries
141+ a.historyAnalytics = analytics
142+ a.historyDomainStats = domains
131 a.renderedHTML = renderAppFrame(renderedPage{143 a.renderedHTML = renderAppFrame(renderedPage{
132 route: RouteHistory,144 route: RouteHistory,
133 subtitle: "Recent visits",145 subtitle: "Recent visits",
134- body: historyHTML(entries),146+ body: historyHTML(historyRenderData{
147+ Entries: entries,
148+ Analytics: analytics,
149+ Domains: domains,
150+ }),
135 }, a.shellTheme)151 }, a.shellTheme)
136 return nil152 return nil
137 }153 }
@@ -224,6 +240,8 @@ func (a *App) loadShellTheme(ctx context.Context) {
224 240
225 func (a *App) resetRouteState() {241 func (a *App) resetRouteState() {
226 a.historyEntries = nil242 a.historyEntries = nil
243+ a.historyAnalytics = nil
244+ a.historyDomainStats = nil
227 a.favorites = nil245 a.favorites = nil
228 a.folders = nil246 a.folders = nil
229 a.tags = nil247 a.tags = nil
M internal/ui/systemviews/app_test.go +3 -3
@@ -81,7 +81,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
8181 assert.NotContains(t, app.renderedHTML, "<body")
8282 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
8383 assert.Contains(t, app.renderedHTML, `data-route="history"`)
84- assert.Contains(t, app.renderedHTML, `class="sv-section"`)
84+ assert.Contains(t, app.renderedHTML, `sv-section`)
8585 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
8686 }
8787
@@ -182,7 +182,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
182182 assert.NotContains(t, app.renderedHTML, "<body")
183183 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
184184 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
185- assert.Contains(t, app.renderedHTML, `class="sv-section"`)
185+ assert.Contains(t, app.renderedHTML, `sv-section`)
186186 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
187187 }
188188
@@ -308,7 +308,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
308308 assert.NotContains(t, app.renderedHTML, "<body")
309309 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
310310 assert.Contains(t, app.renderedHTML, `data-route="config"`)
311- assert.Contains(t, app.renderedHTML, `class="sv-section"`)
311+ assert.Contains(t, app.renderedHTML, `sv-section`)
312312 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
313313 }
314314
@@ -81,7 +81,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
81 assert.NotContains(t, app.renderedHTML, "<body")81 assert.NotContains(t, app.renderedHTML, "<body")
82 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)82 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
83 assert.Contains(t, app.renderedHTML, `data-route="history"`)83 assert.Contains(t, app.renderedHTML, `data-route="history"`)
84- assert.Contains(t, app.renderedHTML, `class="sv-section"`)84+ assert.Contains(t, app.renderedHTML, `sv-section`)
85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
86 }86 }
87 87
@@ -182,7 +182,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
182 assert.NotContains(t, app.renderedHTML, "<body")182 assert.NotContains(t, app.renderedHTML, "<body")
183 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)183 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
184 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)184 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
185- assert.Contains(t, app.renderedHTML, `class="sv-section"`)185+ assert.Contains(t, app.renderedHTML, `sv-section`)
186 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)186 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
187 }187 }
188 188
@@ -308,7 +308,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
308 assert.NotContains(t, app.renderedHTML, "<body")308 assert.NotContains(t, app.renderedHTML, "<body")
309 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)309 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
310 assert.Contains(t, app.renderedHTML, `data-route="config"`)310 assert.Contains(t, app.renderedHTML, `data-route="config"`)
311- assert.Contains(t, app.renderedHTML, `class="sv-section"`)311+ assert.Contains(t, app.renderedHTML, `sv-section`)
312 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)312 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
313 }313 }
314 314
M internal/ui/systemviews/history_view.go +196 -8
@@ -2,14 +2,84 @@ package systemviews
22
33 import (
44 "fmt"
5+ "html"
6+ "net/url"
57 "strings"
8+ "time"
69
710 "github.com/bnema/dumber/internal/domain/entity"
811 )
912
10-func historyHTML(entries []*entity.HistoryEntry) string {
11- items := historyItemsHTML(entries)
12- return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries"))
13+type historyRenderData struct {
14+ Entries []*entity.HistoryEntry
15+ Analytics *entity.HistoryAnalytics
16+ Domains []*entity.DomainStat
17+}
18+
19+func historyHTML(data historyRenderData) string {
20+ sections := []string{
21+ sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
22+ sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
23+ sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)),
24+ }
25+ return strings.Join(sections, "")
26+}
27+
28+func historySummaryHTML(data historyRenderData) string {
29+ entries := int64(countHistoryEntries(data.Entries))
30+ visits := sumHistoryVisits(data.Entries)
31+ uniqueDays := countUniqueHistoryDays(data.Entries)
32+
33+ if data.Analytics != nil {
34+ entries = data.Analytics.TotalEntries
35+ visits = data.Analytics.TotalVisits
36+ uniqueDays = data.Analytics.UniqueDays
37+ }
38+
39+ return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`,
40+ historyStatCardHTML("Entries", entries),
41+ historyStatCardHTML("Visits", visits),
42+ historyStatCardHTML("Days", uniqueDays),
43+ )
44+}
45+
46+func historyStatCardHTML(label string, value int64) string {
47+ return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label))
48+}
49+
50+func historyDomainsHTML(domains []*entity.DomainStat) string {
51+ var chips strings.Builder
52+ for _, domain := range domains {
53+ if domain == nil || strings.TrimSpace(domain.Domain) == "" {
54+ continue
55+ }
56+ chips.WriteString(fmt.Sprintf(
57+ `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`,
58+ html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")),
59+ domain.PageCount,
60+ domain.TotalVisits,
61+ ))
62+ }
63+ if chips.Len() == 0 {
64+ return emptyStateHTML("No domain statistics yet")
65+ }
66+ return `<div class="sv-domain-list">` + chips.String() + `</div>`
67+}
68+
69+func historyTimelineHTML(entries []*entity.HistoryEntry) string {
70+ groups := groupHistoryEntries(entries)
71+ if len(groups) == 0 {
72+ return emptyStateHTML("No history entries")
73+ }
74+
75+ var body strings.Builder
76+ for _, group := range groups {
77+ body.WriteString(`<section class="sv-timeline-group">`)
78+ body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`)
79+ body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries"))
80+ body.WriteString(`</section>`)
81+ }
82+ return body.String()
1383 }
1484
1585 func historyItemsHTML(entries []*entity.HistoryEntry) string {
@@ -18,16 +88,83 @@ func historyItemsHTML(entries []*entity.HistoryEntry) string {
1888 if entry == nil {
1989 continue
2090 }
21- label := entry.Title
22- if label == "" {
23- label = entry.URL
24- }
25- items.WriteString(listRowHTML(linkHTML(entry.URL, label)))
91+ items.WriteString(listRowHTML(historyItemHTML(entry)))
2692 }
2793
2894 return items.String()
2995 }
3096
97+func historyItemHTML(entry *entity.HistoryEntry) string {
98+ label := strings.TrimSpace(entry.Title)
99+ if label == "" {
100+ label = displayHistoryDomain(entry.URL)
101+ }
102+ if label == "" {
103+ label = entry.URL
104+ }
105+
106+ meta := []string{displayHistoryDomain(entry.URL)}
107+ if entry.VisitCount > 1 {
108+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
109+ }
110+ if !entry.LastVisited.IsZero() {
111+ meta = append(meta, formatHistoryTime(entry.LastVisited))
112+ }
113+
114+ return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`,
115+ linkHTML(entry.URL, label),
116+ html.EscapeString(entry.URL),
117+ html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
118+ )
119+}
120+
121+type historyTimelineGroup struct {
122+ Date string
123+ Label string
124+ Entries []*entity.HistoryEntry
125+}
126+
127+func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
128+ groups := make([]historyTimelineGroup, 0)
129+ indexByDate := map[string]int{}
130+ for _, entry := range entries {
131+ if entry == nil {
132+ continue
133+ }
134+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited)
135+ idx, ok := indexByDate[dateKey]
136+ if !ok {
137+ idx = len(groups)
138+ indexByDate[dateKey] = idx
139+ groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
140+ }
141+ groups[idx].Entries = append(groups[idx].Entries, entry)
142+ }
143+ return groups
144+}
145+
146+func historyDateKeyAndLabel(ts time.Time) (string, string) {
147+ if ts.IsZero() {
148+ return "unknown", "Unknown date"
149+ }
150+
151+ local := ts.Local()
152+ dateKey := local.Format("2006-01-02")
153+ today := time.Now().Local()
154+ todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
155+ yesterdayStart := todayStart.AddDate(0, 0, -1)
156+ entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
157+
158+ switch {
159+ case entryStart.Equal(todayStart):
160+ return dateKey, "Today"
161+ case entryStart.Equal(yesterdayStart):
162+ return dateKey, "Yesterday"
163+ default:
164+ return dateKey, local.Format("Mon Jan 2")
165+ }
166+}
167+
31168 func countHistoryEntries(entries []*entity.HistoryEntry) int {
32169 count := 0
33170 for _, entry := range entries {
@@ -37,3 +174,54 @@ func countHistoryEntries(entries []*entity.HistoryEntry) int {
37174 }
38175 return count
39176 }
177+
178+func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
179+ var visits int64
180+ for _, entry := range entries {
181+ if entry == nil {
182+ continue
183+ }
184+ if entry.VisitCount <= 0 {
185+ visits++
186+ continue
187+ }
188+ visits += entry.VisitCount
189+ }
190+ return visits
191+}
192+
193+func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
194+ seen := map[string]struct{}{}
195+ for _, entry := range entries {
196+ if entry == nil || entry.LastVisited.IsZero() {
197+ continue
198+ }
199+ seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
200+ }
201+ return int64(len(seen))
202+}
203+
204+func displayHistoryDomain(raw string) string {
205+ parsed, err := url.Parse(raw)
206+ if err != nil || parsed.Hostname() == "" {
207+ return strings.TrimSpace(raw)
208+ }
209+ return strings.TrimPrefix(parsed.Hostname(), "www.")
210+}
211+
212+func formatHistoryTime(ts time.Time) string {
213+ if ts.IsZero() {
214+ return ""
215+ }
216+ return ts.Local().Format("15:04")
217+}
218+
219+func nonEmptyStrings(values []string) []string {
220+ out := values[:0]
221+ for _, value := range values {
222+ if strings.TrimSpace(value) != "" {
223+ out = append(out, value)
224+ }
225+ }
226+ return out
227+}
@@ -2,14 +2,84 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "html"
6+ "net/url"
5 "strings"7 "strings"
8+ "time"
6 9
7 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
8 )11 )
9 12
10-func historyHTML(entries []*entity.HistoryEntry) string {13+type historyRenderData struct {
11- items := historyItemsHTML(entries)14+ Entries []*entity.HistoryEntry
12- return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries"))15+ Analytics *entity.HistoryAnalytics
16+ Domains []*entity.DomainStat
17+}
18+
19+func historyHTML(data historyRenderData) string {
20+ sections := []string{
21+ sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
22+ sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
23+ sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)),
24+ }
25+ return strings.Join(sections, "")
26+}
27+
28+func historySummaryHTML(data historyRenderData) string {
29+ entries := int64(countHistoryEntries(data.Entries))
30+ visits := sumHistoryVisits(data.Entries)
31+ uniqueDays := countUniqueHistoryDays(data.Entries)
32+
33+ if data.Analytics != nil {
34+ entries = data.Analytics.TotalEntries
35+ visits = data.Analytics.TotalVisits
36+ uniqueDays = data.Analytics.UniqueDays
37+ }
38+
39+ return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`,
40+ historyStatCardHTML("Entries", entries),
41+ historyStatCardHTML("Visits", visits),
42+ historyStatCardHTML("Days", uniqueDays),
43+ )
44+}
45+
46+func historyStatCardHTML(label string, value int64) string {
47+ return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label))
48+}
49+
50+func historyDomainsHTML(domains []*entity.DomainStat) string {
51+ var chips strings.Builder
52+ for _, domain := range domains {
53+ if domain == nil || strings.TrimSpace(domain.Domain) == "" {
54+ continue
55+ }
56+ chips.WriteString(fmt.Sprintf(
57+ `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`,
58+ html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")),
59+ domain.PageCount,
60+ domain.TotalVisits,
61+ ))
62+ }
63+ if chips.Len() == 0 {
64+ return emptyStateHTML("No domain statistics yet")
65+ }
66+ return `<div class="sv-domain-list">` + chips.String() + `</div>`
67+}
68+
69+func historyTimelineHTML(entries []*entity.HistoryEntry) string {
70+ groups := groupHistoryEntries(entries)
71+ if len(groups) == 0 {
72+ return emptyStateHTML("No history entries")
73+ }
74+
75+ var body strings.Builder
76+ for _, group := range groups {
77+ body.WriteString(`<section class="sv-timeline-group">`)
78+ body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`)
79+ body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries"))
80+ body.WriteString(`</section>`)
81+ }
82+ return body.String()
13 }83 }
14 84
15 func historyItemsHTML(entries []*entity.HistoryEntry) string {85 func historyItemsHTML(entries []*entity.HistoryEntry) string {
@@ -18,16 +88,83 @@ func historyItemsHTML(entries []*entity.HistoryEntry) string {
18 if entry == nil {88 if entry == nil {
19 continue89 continue
20 }90 }
21- label := entry.Title91+ items.WriteString(listRowHTML(historyItemHTML(entry)))
22- if label == "" {
23- label = entry.URL
24- }
25- items.WriteString(listRowHTML(linkHTML(entry.URL, label)))
26 }92 }
27 93
28 return items.String()94 return items.String()
29 }95 }
30 96
97+func historyItemHTML(entry *entity.HistoryEntry) string {
98+ label := strings.TrimSpace(entry.Title)
99+ if label == "" {
100+ label = displayHistoryDomain(entry.URL)
101+ }
102+ if label == "" {
103+ label = entry.URL
104+ }
105+
106+ meta := []string{displayHistoryDomain(entry.URL)}
107+ if entry.VisitCount > 1 {
108+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
109+ }
110+ if !entry.LastVisited.IsZero() {
111+ meta = append(meta, formatHistoryTime(entry.LastVisited))
112+ }
113+
114+ return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`,
115+ linkHTML(entry.URL, label),
116+ html.EscapeString(entry.URL),
117+ html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
118+ )
119+}
120+
121+type historyTimelineGroup struct {
122+ Date string
123+ Label string
124+ Entries []*entity.HistoryEntry
125+}
126+
127+func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
128+ groups := make([]historyTimelineGroup, 0)
129+ indexByDate := map[string]int{}
130+ for _, entry := range entries {
131+ if entry == nil {
132+ continue
133+ }
134+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited)
135+ idx, ok := indexByDate[dateKey]
136+ if !ok {
137+ idx = len(groups)
138+ indexByDate[dateKey] = idx
139+ groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
140+ }
141+ groups[idx].Entries = append(groups[idx].Entries, entry)
142+ }
143+ return groups
144+}
145+
146+func historyDateKeyAndLabel(ts time.Time) (string, string) {
147+ if ts.IsZero() {
148+ return "unknown", "Unknown date"
149+ }
150+
151+ local := ts.Local()
152+ dateKey := local.Format("2006-01-02")
153+ today := time.Now().Local()
154+ todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
155+ yesterdayStart := todayStart.AddDate(0, 0, -1)
156+ entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
157+
158+ switch {
159+ case entryStart.Equal(todayStart):
160+ return dateKey, "Today"
161+ case entryStart.Equal(yesterdayStart):
162+ return dateKey, "Yesterday"
163+ default:
164+ return dateKey, local.Format("Mon Jan 2")
165+ }
166+}
167+
31 func countHistoryEntries(entries []*entity.HistoryEntry) int {168 func countHistoryEntries(entries []*entity.HistoryEntry) int {
32 count := 0169 count := 0
33 for _, entry := range entries {170 for _, entry := range entries {
@@ -37,3 +174,54 @@ func countHistoryEntries(entries []*entity.HistoryEntry) int {
37 }174 }
38 return count175 return count
39 }176 }
177+
178+func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
179+ var visits int64
180+ for _, entry := range entries {
181+ if entry == nil {
182+ continue
183+ }
184+ if entry.VisitCount <= 0 {
185+ visits++
186+ continue
187+ }
188+ visits += entry.VisitCount
189+ }
190+ return visits
191+}
192+
193+func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
194+ seen := map[string]struct{}{}
195+ for _, entry := range entries {
196+ if entry == nil || entry.LastVisited.IsZero() {
197+ continue
198+ }
199+ seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
200+ }
201+ return int64(len(seen))
202+}
203+
204+func displayHistoryDomain(raw string) string {
205+ parsed, err := url.Parse(raw)
206+ if err != nil || parsed.Hostname() == "" {
207+ return strings.TrimSpace(raw)
208+ }
209+ return strings.TrimPrefix(parsed.Hostname(), "www.")
210+}
211+
212+func formatHistoryTime(ts time.Time) string {
213+ if ts.IsZero() {
214+ return ""
215+ }
216+ return ts.Local().Format("15:04")
217+}
218+
219+func nonEmptyStrings(values []string) []string {
220+ out := values[:0]
221+ for _, value := range values {
222+ if strings.TrimSpace(value) != "" {
223+ out = append(out, value)
224+ }
225+ }
226+ return out
227+}
M internal/ui/systemviews/href_test.go +2 -2
@@ -11,10 +11,10 @@ import (
1111 func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
1212 t.Parallel()
1313
14- html := historyHTML([]*entity.HistoryEntry{
14+ html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{
1515 {URL: "javascript:alert(1)", Title: "Bad"},
1616 {URL: "https://example.com", Title: "Good"},
17- })
17+ }})
1818
1919 require.NotContains(t, html, `href="javascript:alert(1)"`)
2020 assert.Contains(t, html, `href="#"`)
@@ -11,10 +11,10 @@ import (
11 func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {11 func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
12 t.Parallel()12 t.Parallel()
13 13
14- html := historyHTML([]*entity.HistoryEntry{14+ html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{
15 {URL: "javascript:alert(1)", Title: "Bad"},15 {URL: "javascript:alert(1)", Title: "Bad"},
16 {URL: "https://example.com", Title: "Good"},16 {URL: "https://example.com", Title: "Good"},
17- })17+ }})
18 18
19 require.NotContains(t, html, `href="javascript:alert(1)"`)19 require.NotContains(t, html, `href="javascript:alert(1)"`)
20 assert.Contains(t, html, `href="#"`)20 assert.Contains(t, html, `href="#"`)

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