detached c5f1087

feat(persistence): add analytics queries and repository methods for history

commit
c5f10875b5feac801d838c7206255ffee57f1e84
author
brice · · 2025-12-16 22:11 UTC
committer
brice · · 2025-12-16 22:14 UTC
parent
1cb11c4
signature
unsigned
5 files · +278 -0
M internal/domain/repository/history.go +15 -0
@@ -32,4 +32,19 @@ type HistoryRepository interface {
3232
3333 // DeleteAll removes all history entries.
3434 DeleteAll(ctx context.Context) error
35+
36+ // DeleteByDomain removes all history entries for a domain.
37+ DeleteByDomain(ctx context.Context, domain string) error
38+
39+ // GetStats retrieves overall history statistics.
40+ GetStats(ctx context.Context) (*entity.HistoryStats, error)
41+
42+ // GetDomainStats retrieves per-domain statistics.
43+ GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
44+
45+ // GetHourlyDistribution retrieves hourly visit distribution.
46+ GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error)
47+
48+ // GetDailyVisitCount retrieves daily visit counts for the given period.
49+ GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error)
3550 }
@@ -32,4 +32,19 @@ type HistoryRepository interface {
32 32
33 // DeleteAll removes all history entries.33 // DeleteAll removes all history entries.
34 DeleteAll(ctx context.Context) error34 DeleteAll(ctx context.Context) error
35+
36+ // DeleteByDomain removes all history entries for a domain.
37+ DeleteByDomain(ctx context.Context, domain string) error
38+
39+ // GetStats retrieves overall history statistics.
40+ GetStats(ctx context.Context) (*entity.HistoryStats, error)
41+
42+ // GetDomainStats retrieves per-domain statistics.
43+ GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
44+
45+ // GetHourlyDistribution retrieves hourly visit distribution.
46+ GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error)
47+
48+ // GetDailyVisitCount retrieves daily visit counts for the given period.
49+ GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error)
35 }50 }
M internal/infrastructure/persistence/sqlite/history_repo.go +101 -0
@@ -94,6 +94,107 @@ func (r *historyRepo) DeleteAll(ctx context.Context) error {
9494 return r.queries.DeleteAllHistory(ctx)
9595 }
9696
97+func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
98+ return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{
99+ Column1: sql.NullString{String: domain, Valid: true},
100+ Column2: sql.NullString{String: domain, Valid: true},
101+ Column3: sql.NullString{String: domain, Valid: true},
102+ Column4: sql.NullString{String: domain, Valid: true},
103+ })
104+}
105+
106+func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
107+ row, err := r.queries.GetHistoryStats(ctx)
108+ if err != nil {
109+ return nil, err
110+ }
111+
112+ // Handle the interface{} type for total_visits
113+ var totalVisits int64
114+ if v, ok := row.TotalVisits.(int64); ok {
115+ totalVisits = v
116+ } else if v, ok := row.TotalVisits.(float64); ok {
117+ totalVisits = int64(v)
118+ }
119+
120+ return &entity.HistoryStats{
121+ TotalEntries: row.TotalEntries,
122+ TotalVisits: totalVisits,
123+ UniqueDays: row.UniqueDays,
124+ }, nil
125+}
126+
127+func (r *historyRepo) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
128+ rows, err := r.queries.GetDomainStats(ctx, int64(limit))
129+ if err != nil {
130+ return nil, err
131+ }
132+
133+ stats := make([]*entity.DomainStat, len(rows))
134+ for i, row := range rows {
135+ var totalVisits int64
136+ if row.TotalVisits.Valid {
137+ totalVisits = int64(row.TotalVisits.Float64)
138+ }
139+
140+ var lastVisit time.Time
141+ if v, ok := row.LastVisit.(string); ok && v != "" {
142+ lastVisit, _ = time.Parse("2006-01-02 15:04:05", v)
143+ }
144+
145+ stats[i] = &entity.DomainStat{
146+ Domain: row.Domain,
147+ PageCount: row.PageCount,
148+ TotalVisits: totalVisits,
149+ LastVisit: lastVisit,
150+ }
151+ }
152+ return stats, nil
153+}
154+
155+func (r *historyRepo) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
156+ rows, err := r.queries.GetHourlyDistribution(ctx)
157+ if err != nil {
158+ return nil, err
159+ }
160+
161+ dist := make([]*entity.HourlyDistribution, len(rows))
162+ for i, row := range rows {
163+ dist[i] = &entity.HourlyDistribution{
164+ Hour: int(row.Hour),
165+ VisitCount: row.VisitCount,
166+ }
167+ }
168+ return dist, nil
169+}
170+
171+func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
172+ rows, err := r.queries.GetDailyVisitCount(ctx, daysAgo)
173+ if err != nil {
174+ return nil, err
175+ }
176+
177+ counts := make([]*entity.DailyVisitCount, len(rows))
178+ for i, row := range rows {
179+ var day string
180+ if v, ok := row.Day.(string); ok {
181+ day = v
182+ }
183+
184+ var visits int64
185+ if row.Visits.Valid {
186+ visits = int64(row.Visits.Float64)
187+ }
188+
189+ counts[i] = &entity.DailyVisitCount{
190+ Day: day,
191+ Entries: row.Entries,
192+ Visits: visits,
193+ }
194+ }
195+ return counts, nil
196+}
197+
97198 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
98199 return &entity.HistoryEntry{
99200 ID: row.ID,
@@ -94,6 +94,107 @@ func (r *historyRepo) DeleteAll(ctx context.Context) error {
94 return r.queries.DeleteAllHistory(ctx)94 return r.queries.DeleteAllHistory(ctx)
95 }95 }
96 96
97+func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
98+ return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{
99+ Column1: sql.NullString{String: domain, Valid: true},
100+ Column2: sql.NullString{String: domain, Valid: true},
101+ Column3: sql.NullString{String: domain, Valid: true},
102+ Column4: sql.NullString{String: domain, Valid: true},
103+ })
104+}
105+
106+func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
107+ row, err := r.queries.GetHistoryStats(ctx)
108+ if err != nil {
109+ return nil, err
110+ }
111+
112+ // Handle the interface{} type for total_visits
113+ var totalVisits int64
114+ if v, ok := row.TotalVisits.(int64); ok {
115+ totalVisits = v
116+ } else if v, ok := row.TotalVisits.(float64); ok {
117+ totalVisits = int64(v)
118+ }
119+
120+ return &entity.HistoryStats{
121+ TotalEntries: row.TotalEntries,
122+ TotalVisits: totalVisits,
123+ UniqueDays: row.UniqueDays,
124+ }, nil
125+}
126+
127+func (r *historyRepo) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
128+ rows, err := r.queries.GetDomainStats(ctx, int64(limit))
129+ if err != nil {
130+ return nil, err
131+ }
132+
133+ stats := make([]*entity.DomainStat, len(rows))
134+ for i, row := range rows {
135+ var totalVisits int64
136+ if row.TotalVisits.Valid {
137+ totalVisits = int64(row.TotalVisits.Float64)
138+ }
139+
140+ var lastVisit time.Time
141+ if v, ok := row.LastVisit.(string); ok && v != "" {
142+ lastVisit, _ = time.Parse("2006-01-02 15:04:05", v)
143+ }
144+
145+ stats[i] = &entity.DomainStat{
146+ Domain: row.Domain,
147+ PageCount: row.PageCount,
148+ TotalVisits: totalVisits,
149+ LastVisit: lastVisit,
150+ }
151+ }
152+ return stats, nil
153+}
154+
155+func (r *historyRepo) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
156+ rows, err := r.queries.GetHourlyDistribution(ctx)
157+ if err != nil {
158+ return nil, err
159+ }
160+
161+ dist := make([]*entity.HourlyDistribution, len(rows))
162+ for i, row := range rows {
163+ dist[i] = &entity.HourlyDistribution{
164+ Hour: int(row.Hour),
165+ VisitCount: row.VisitCount,
166+ }
167+ }
168+ return dist, nil
169+}
170+
171+func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
172+ rows, err := r.queries.GetDailyVisitCount(ctx, daysAgo)
173+ if err != nil {
174+ return nil, err
175+ }
176+
177+ counts := make([]*entity.DailyVisitCount, len(rows))
178+ for i, row := range rows {
179+ var day string
180+ if v, ok := row.Day.(string); ok {
181+ day = v
182+ }
183+
184+ var visits int64
185+ if row.Visits.Valid {
186+ visits = int64(row.Visits.Float64)
187+ }
188+
189+ counts[i] = &entity.DailyVisitCount{
190+ Day: day,
191+ Entries: row.Entries,
192+ Visits: visits,
193+ }
194+ }
195+ return counts, nil
196+}
197+
97 func historyFromRow(row sqlc.History) *entity.HistoryEntry {198 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
98 return &entity.HistoryEntry{199 return &entity.HistoryEntry{
99 ID: row.ID,200 ID: row.ID,
M internal/infrastructure/persistence/sqlite/queries/history.sql +15 -0
@@ -33,3 +33,18 @@ DELETE FROM history WHERE last_visited < ?;
3333
3434 -- name: DeleteAllHistory :exec
3535 DELETE FROM history;
36+
37+-- name: GetHistoryStats :one
38+SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
39+
40+-- name: GetDomainStats :many
41+SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?;
42+
43+-- name: GetHourlyDistribution :many
44+SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;
45+
46+-- name: GetDailyVisitCount :many
47+SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
48+
49+-- name: DeleteHistoryByDomain :exec
50+DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
@@ -33,3 +33,18 @@ DELETE FROM history WHERE last_visited < ?;
33 33
34 -- name: DeleteAllHistory :exec34 -- name: DeleteAllHistory :exec
35 DELETE FROM history;35 DELETE FROM history;
36+
37+-- name: GetHistoryStats :one
38+SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
39+
40+-- name: GetDomainStats :many
41+SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?;
42+
43+-- name: GetHourlyDistribution :many
44+SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;
45+
46+-- name: GetDailyVisitCount :many
47+SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
48+
49+-- name: DeleteHistoryByDomain :exec
50+DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +142 -0
@@ -19,6 +19,27 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
1919 return err
2020 }
2121
22+const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
23+DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?
24+`
25+
26+type DeleteHistoryByDomainParams struct {
27+ Column1 sql.NullString `json:"column_1"`
28+ Column2 sql.NullString `json:"column_2"`
29+ Column3 sql.NullString `json:"column_3"`
30+ Column4 sql.NullString `json:"column_4"`
31+}
32+
33+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
34+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
35+ arg.Column1,
36+ arg.Column2,
37+ arg.Column3,
38+ arg.Column4,
39+ )
40+ return err
41+}
42+
2243 const DeleteHistoryByID = `-- name: DeleteHistoryByID :exec
2344 DELETE FROM history WHERE id = ?
2445 `
@@ -37,6 +58,78 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
3758 return err
3859 }
3960
61+const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
62+SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
63+`
64+
65+type GetDailyVisitCountRow struct {
66+ Day interface{} `json:"day"`
67+ Entries int64 `json:"entries"`
68+ Visits sql.NullFloat64 `json:"visits"`
69+}
70+
71+func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error) {
72+ rows, err := q.db.QueryContext(ctx, GetDailyVisitCount, date)
73+ if err != nil {
74+ return nil, err
75+ }
76+ defer rows.Close()
77+ items := []GetDailyVisitCountRow{}
78+ for rows.Next() {
79+ var i GetDailyVisitCountRow
80+ if err := rows.Scan(&i.Day, &i.Entries, &i.Visits); err != nil {
81+ return nil, err
82+ }
83+ items = append(items, i)
84+ }
85+ if err := rows.Close(); err != nil {
86+ return nil, err
87+ }
88+ if err := rows.Err(); err != nil {
89+ return nil, err
90+ }
91+ return items, nil
92+}
93+
94+const GetDomainStats = `-- name: GetDomainStats :many
95+SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?
96+`
97+
98+type GetDomainStatsRow struct {
99+ Domain string `json:"domain"`
100+ PageCount int64 `json:"page_count"`
101+ TotalVisits sql.NullFloat64 `json:"total_visits"`
102+ LastVisit interface{} `json:"last_visit"`
103+}
104+
105+func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error) {
106+ rows, err := q.db.QueryContext(ctx, GetDomainStats, limit)
107+ if err != nil {
108+ return nil, err
109+ }
110+ defer rows.Close()
111+ items := []GetDomainStatsRow{}
112+ for rows.Next() {
113+ var i GetDomainStatsRow
114+ if err := rows.Scan(
115+ &i.Domain,
116+ &i.PageCount,
117+ &i.TotalVisits,
118+ &i.LastVisit,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
40133 const GetHistoryByURL = `-- name: GetHistoryByURL :one
41134 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
42135 `
@@ -56,6 +149,55 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
56149 return i, err
57150 }
58151
152+const GetHistoryStats = `-- name: GetHistoryStats :one
153+SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history
154+`
155+
156+type GetHistoryStatsRow struct {
157+ TotalEntries int64 `json:"total_entries"`
158+ TotalVisits interface{} `json:"total_visits"`
159+ UniqueDays int64 `json:"unique_days"`
160+}
161+
162+func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) {
163+ row := q.db.QueryRowContext(ctx, GetHistoryStats)
164+ var i GetHistoryStatsRow
165+ err := row.Scan(&i.TotalEntries, &i.TotalVisits, &i.UniqueDays)
166+ return i, err
167+}
168+
169+const GetHourlyDistribution = `-- name: GetHourlyDistribution :many
170+SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour
171+`
172+
173+type GetHourlyDistributionRow struct {
174+ Hour int64 `json:"hour"`
175+ VisitCount int64 `json:"visit_count"`
176+}
177+
178+func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) {
179+ rows, err := q.db.QueryContext(ctx, GetHourlyDistribution)
180+ if err != nil {
181+ return nil, err
182+ }
183+ defer rows.Close()
184+ items := []GetHourlyDistributionRow{}
185+ for rows.Next() {
186+ var i GetHourlyDistributionRow
187+ if err := rows.Scan(&i.Hour, &i.VisitCount); err != nil {
188+ return nil, err
189+ }
190+ items = append(items, i)
191+ }
192+ if err := rows.Close(); err != nil {
193+ return nil, err
194+ }
195+ if err := rows.Err(); err != nil {
196+ return nil, err
197+ }
198+ return items, nil
199+}
200+
59201 const GetRecentHistory = `-- name: GetRecentHistory :many
60202 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
61203 `
@@ -19,6 +19,27 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
19 return err19 return err
20 }20 }
21 21
22+const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
23+DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?
24+`
25+
26+type DeleteHistoryByDomainParams struct {
27+ Column1 sql.NullString `json:"column_1"`
28+ Column2 sql.NullString `json:"column_2"`
29+ Column3 sql.NullString `json:"column_3"`
30+ Column4 sql.NullString `json:"column_4"`
31+}
32+
33+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
34+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
35+ arg.Column1,
36+ arg.Column2,
37+ arg.Column3,
38+ arg.Column4,
39+ )
40+ return err
41+}
42+
22 const DeleteHistoryByID = `-- name: DeleteHistoryByID :exec43 const DeleteHistoryByID = `-- name: DeleteHistoryByID :exec
23 DELETE FROM history WHERE id = ?44 DELETE FROM history WHERE id = ?
24 `45 `
@@ -37,6 +58,78 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
37 return err58 return err
38 }59 }
39 60
61+const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
62+SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
63+`
64+
65+type GetDailyVisitCountRow struct {
66+ Day interface{} `json:"day"`
67+ Entries int64 `json:"entries"`
68+ Visits sql.NullFloat64 `json:"visits"`
69+}
70+
71+func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error) {
72+ rows, err := q.db.QueryContext(ctx, GetDailyVisitCount, date)
73+ if err != nil {
74+ return nil, err
75+ }
76+ defer rows.Close()
77+ items := []GetDailyVisitCountRow{}
78+ for rows.Next() {
79+ var i GetDailyVisitCountRow
80+ if err := rows.Scan(&i.Day, &i.Entries, &i.Visits); err != nil {
81+ return nil, err
82+ }
83+ items = append(items, i)
84+ }
85+ if err := rows.Close(); err != nil {
86+ return nil, err
87+ }
88+ if err := rows.Err(); err != nil {
89+ return nil, err
90+ }
91+ return items, nil
92+}
93+
94+const GetDomainStats = `-- name: GetDomainStats :many
95+SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?
96+`
97+
98+type GetDomainStatsRow struct {
99+ Domain string `json:"domain"`
100+ PageCount int64 `json:"page_count"`
101+ TotalVisits sql.NullFloat64 `json:"total_visits"`
102+ LastVisit interface{} `json:"last_visit"`
103+}
104+
105+func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error) {
106+ rows, err := q.db.QueryContext(ctx, GetDomainStats, limit)
107+ if err != nil {
108+ return nil, err
109+ }
110+ defer rows.Close()
111+ items := []GetDomainStatsRow{}
112+ for rows.Next() {
113+ var i GetDomainStatsRow
114+ if err := rows.Scan(
115+ &i.Domain,
116+ &i.PageCount,
117+ &i.TotalVisits,
118+ &i.LastVisit,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
40 const GetHistoryByURL = `-- name: GetHistoryByURL :one133 const GetHistoryByURL = `-- name: GetHistoryByURL :one
41 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1134 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
42 `135 `
@@ -56,6 +149,55 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
56 return i, err149 return i, err
57 }150 }
58 151
152+const GetHistoryStats = `-- name: GetHistoryStats :one
153+SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history
154+`
155+
156+type GetHistoryStatsRow struct {
157+ TotalEntries int64 `json:"total_entries"`
158+ TotalVisits interface{} `json:"total_visits"`
159+ UniqueDays int64 `json:"unique_days"`
160+}
161+
162+func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) {
163+ row := q.db.QueryRowContext(ctx, GetHistoryStats)
164+ var i GetHistoryStatsRow
165+ err := row.Scan(&i.TotalEntries, &i.TotalVisits, &i.UniqueDays)
166+ return i, err
167+}
168+
169+const GetHourlyDistribution = `-- name: GetHourlyDistribution :many
170+SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour
171+`
172+
173+type GetHourlyDistributionRow struct {
174+ Hour int64 `json:"hour"`
175+ VisitCount int64 `json:"visit_count"`
176+}
177+
178+func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) {
179+ rows, err := q.db.QueryContext(ctx, GetHourlyDistribution)
180+ if err != nil {
181+ return nil, err
182+ }
183+ defer rows.Close()
184+ items := []GetHourlyDistributionRow{}
185+ for rows.Next() {
186+ var i GetHourlyDistributionRow
187+ if err := rows.Scan(&i.Hour, &i.VisitCount); err != nil {
188+ return nil, err
189+ }
190+ items = append(items, i)
191+ }
192+ if err := rows.Close(); err != nil {
193+ return nil, err
194+ }
195+ if err := rows.Err(); err != nil {
196+ return nil, err
197+ }
198+ return items, nil
199+}
200+
59 const GetRecentHistory = `-- name: GetRecentHistory :many201 const GetRecentHistory = `-- name: GetRecentHistory :many
60 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?202 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
61 `203 `
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
@@ -18,6 +18,7 @@ type Querier interface {
1818 DeleteAllHistory(ctx context.Context) error
1919 DeleteFavorite(ctx context.Context, id int64) error
2020 DeleteFolder(ctx context.Context, id int64) error
21+ DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
2122 DeleteHistoryByID(ctx context.Context, id int64) error
2223 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
2324 DeleteTag(ctx context.Context, id int64) error
@@ -27,6 +28,8 @@ type Querier interface {
2728 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
2829 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
2930 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
31+ GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
32+ GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
3033 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
3134 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
3235 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
@@ -34,6 +37,8 @@ type Querier interface {
3437 GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
3538 GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
3639 GetHistoryByURL(ctx context.Context, url string) (History, error)
40+ GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
41+ GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
3742 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
3843 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
3944 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
@@ -18,6 +18,7 @@ type Querier interface {
18 DeleteAllHistory(ctx context.Context) error18 DeleteAllHistory(ctx context.Context) error
19 DeleteFavorite(ctx context.Context, id int64) error19 DeleteFavorite(ctx context.Context, id int64) error
20 DeleteFolder(ctx context.Context, id int64) error20 DeleteFolder(ctx context.Context, id int64) error
21+ DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
21 DeleteHistoryByID(ctx context.Context, id int64) error22 DeleteHistoryByID(ctx context.Context, id int64) error
22 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
23 DeleteTag(ctx context.Context, id int64) error24 DeleteTag(ctx context.Context, id int64) error
@@ -27,6 +28,8 @@ type Querier interface {
27 GetAllTags(ctx context.Context) ([]FavoriteTag, error)28 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
28 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)29 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
29 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)30 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
31+ GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
32+ GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
30 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)33 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
31 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)34 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
32 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)35 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
@@ -34,6 +37,8 @@ type Querier interface {
34 GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)37 GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
35 GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)38 GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
36 GetHistoryByURL(ctx context.Context, url string) (History, error)39 GetHistoryByURL(ctx context.Context, url string) (History, error)
40+ GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
41+ GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
37 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)42 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
38 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)43 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
39 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)44 GetTagByID(ctx context.Context, id int64) (FavoriteTag, 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