main pico / pkg / db / postgres / analytics.go
Antonio Mika  ·  2026-10-10
  1package postgres
  2
  3import (
  4	"fmt"
  5	"sort"
  6	"sync"
  7	"time"
  8
  9	"github.com/picosh/pico/pkg/db"
 10)
 11
 12// mobileUserAgentExpr is a SQL expression to detect mobile user agents.
 13const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'`
 14
 15func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
 16	_, err := me.Db.Exec(
 17		`INSERT INTO analytics_visits (user_id, project_id, post_id, namespace, host, path, ip_address, user_agent, referer, status, content_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`,
 18		visit.UserID,
 19		newNullString(visit.ProjectID),
 20		newNullString(visit.PostID),
 21		newNullString(visit.Namespace),
 22		visit.Host,
 23		visit.Path,
 24		visit.IpAddress,
 25		visit.UserAgent,
 26		visit.Referer,
 27		visit.Status,
 28		visit.ContentType,
 29	)
 30	return err
 31}
 32
 33func visitFilterBy(opts *db.SummaryOpts) (string, string) {
 34	where := ""
 35	val := ""
 36	if opts.Host != "" {
 37		where = "host"
 38		val = opts.Host
 39	} else if opts.Path != "" {
 40		where = "path"
 41		val = opts.Path
 42	}
 43
 44	return where, val
 45}
 46
 47func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
 48	var intervals, currentIntervals []*db.VisitInterval
 49	var sumErr, rawErr error
 50
 51	var wg sync.WaitGroup
 52	wg.Add(2)
 53
 54	go func() {
 55		defer wg.Done()
 56		intervals, sumErr = me.visitUniqueFromSummary(opts)
 57	}()
 58	go func() {
 59		defer wg.Done()
 60		currentIntervals, rawErr = me.visitUniqueFromRaw(opts)
 61	}()
 62
 63	wg.Wait()
 64
 65	if sumErr != nil {
 66		return nil, fmt.Errorf("query summary visits: %w", sumErr)
 67	}
 68	if rawErr != nil {
 69		return nil, fmt.Errorf("query raw visits: %w", rawErr)
 70	}
 71
 72	// Merge: current month data may overlap with summary data, combine counts
 73	return mergeVisitIntervals(intervals, currentIntervals), nil
 74}
 75
 76// visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data.
 77func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
 78	now := time.Now()
 79	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
 80	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
 81
 82	// If origin is in the previous month or later, raw data covers it — no summary to fetch.
 83	if !opts.Origin.Before(previousMonthStart) {
 84		return nil, nil
 85	}
 86
 87	where := ""
 88	args := []interface{}{opts.UserID, opts.Origin, currentMonthStart}
 89	argIdx := 4
 90	if opts.Host != "" {
 91		where = "AND host = $" + fmt.Sprintf("%d", argIdx)
 92		args = append(args, opts.Host)
 93	}
 94
 95	query := fmt.Sprintf(`
 96		SELECT
 97			date_trunc('%s', visit_date)::timestamptz as interval_start,
 98			sum(unique_visits) as unique_visitors,
 99			sum(mobile_visits) as mobile_visits,
100			sum(desktop_visits) as desktop_visits
101		FROM analytics_monthly_visits
102		WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s
103		GROUP BY interval_start
104		ORDER BY interval_start`, opts.Interval, where)
105
106	rows, err := me.Db.Queryx(query, args...)
107	if err != nil {
108		return nil, err
109	}
110	defer func() { _ = rows.Close() }()
111
112	var intervals []*db.VisitInterval
113	for rows.Next() {
114		interval := &db.VisitInterval{}
115		if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil {
116			return nil, err
117		}
118		intervals = append(intervals, interval)
119	}
120	return intervals, rows.Err()
121}
122
123// visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months.
124// This covers the gap between the last aggregated month and the current month.
125func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
126	now := time.Now()
127	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
128	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
129
130	where, with := visitFilterBy(opts)
131
132	// Determine the effective start: max(origin, previousMonthStart)
133	effectiveStart := previousMonthStart
134	if opts.Origin.After(previousMonthStart) {
135		effectiveStart = opts.Origin
136	}
137
138	uniqueVisitors := fmt.Sprintf(`
139		SELECT
140			date_trunc('%s', created_at)::timestamptz as interval_start,
141			count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors,
142			count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors,
143			count(DISTINCT ip_address) as unique_visitors
144		FROM analytics_visits
145		WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404
146		GROUP BY interval_start
147		ORDER BY interval_start`,
148		opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where)
149
150	rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
151	if err != nil {
152		return nil, err
153	}
154	defer func() { _ = rows.Close() }()
155
156	var intervals []*db.VisitInterval
157	for rows.Next() {
158		interval := &db.VisitInterval{}
159		if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil {
160			return nil, err
161		}
162		intervals = append(intervals, interval)
163	}
164	return intervals, rows.Err()
165}
166
167// mergeVisitIntervals combines historical (summary table) and current (raw) intervals.
168// Summary data is preferred when both sources have the same interval to avoid double-counting.
169func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval {
170	if len(historical) == 0 {
171		return current
172	}
173	if len(current) == 0 {
174		return historical
175	}
176
177	// Build a map by interval timestamp for merging.
178	// Summary data takes precedence over raw data for the same interval
179	// (e.g. when a month is aggregated but raw data still exists in a local dump).
180	intervalMap := make(map[int64]*db.VisitInterval)
181	for _, ci := range current {
182		ts := ci.Interval.Unix()
183		intervalMap[ts] = ci
184	}
185	for _, hi := range historical {
186		ts := hi.Interval.Unix()
187		intervalMap[ts] = hi // summary overwrites raw if both exist
188	}
189
190	// Sort by interval
191	result := make([]*db.VisitInterval, 0, len(intervalMap))
192	for _, iv := range intervalMap {
193		result = append(result, iv)
194	}
195	sort.Slice(result, func(i, j int) bool {
196		return result[i].Interval.Before(*result[j].Interval)
197	})
198	return result
199}
200
201// mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs,
202// then returns the top 10 by total count.
203func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl {
204	if len(historical) == 0 {
205		return current
206	}
207	if len(current) == 0 {
208		return historical
209	}
210
211	// Build a map by URL for merging
212	urlMap := make(map[string]*db.VisitUrl)
213	for _, hu := range historical {
214		urlMap[hu.Url] = hu
215	}
216
217	for _, cu := range current {
218		if existing, ok := urlMap[cu.Url]; ok {
219			existing.Count += cu.Count
220		} else {
221			urlMap[cu.Url] = cu
222		}
223	}
224
225	// Sort by count descending and take top 10
226	result := make([]*db.VisitUrl, 0, len(urlMap))
227	for _, u := range urlMap {
228		result = append(result, u)
229	}
230	sort.Slice(result, func(i, j int) bool {
231		return result[i].Count > result[j].Count
232	})
233	if len(result) > 10 {
234		result = result[:10]
235	}
236	return result
237}
238
239// mergeTopReferers combines historical and current top referers, summing counts for overlapping referers,
240// then returns the top 10 by total count.
241func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl {
242	return mergeTopUrls(historical, current) // Same logic as mergeTopUrls
243}
244
245// mergeHosts combines historical and current hosts, summing counts for overlapping hosts,
246// then returns sorted by total count descending.
247func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl {
248	if len(historical) == 0 {
249		return current
250	}
251	if len(current) == 0 {
252		return historical
253	}
254
255	// Build a map by host for merging
256	hostMap := make(map[string]*db.VisitUrl)
257	for _, hu := range historical {
258		hostMap[hu.Url] = hu
259	}
260
261	for _, cu := range current {
262		if existing, ok := hostMap[cu.Url]; ok {
263			existing.Count += cu.Count
264		} else {
265			hostMap[cu.Url] = cu
266		}
267	}
268
269	// Sort by count descending
270	result := make([]*db.VisitUrl, 0, len(hostMap))
271	for _, h := range hostMap {
272		result = append(result, h)
273	}
274	sort.Slice(result, func(i, j int) bool {
275		return result[i].Count > result[j].Count
276	})
277	return result
278}
279
280func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
281	var historical, current []*db.VisitUrl
282	var histErr, rawErr error
283
284	var wg sync.WaitGroup
285	wg.Add(2)
286
287	go func() {
288		defer wg.Done()
289		historical, histErr = me.visitRefererFromSummary(opts)
290	}()
291	go func() {
292		defer wg.Done()
293		current, rawErr = me.visitRefererFromRaw(opts)
294	}()
295
296	wg.Wait()
297
298	if histErr != nil {
299		return nil, fmt.Errorf("query summary referers: %w", histErr)
300	}
301	if rawErr != nil {
302		return nil, fmt.Errorf("query raw referers: %w", rawErr)
303	}
304
305	return mergeTopReferers(historical, current), nil
306}
307
308// visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data.
309func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
310	now := time.Now()
311	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
312	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
313
314	// If origin is in the previous month or later, raw data covers it — no summary to fetch.
315	if !opts.Origin.Before(previousMonthStart) {
316		return nil, nil
317	}
318
319	// Clamp origin to month boundary for summary table lookup
320	originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
321
322	where := ""
323	args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
324	if opts.Host != "" {
325		where = "AND host = $4"
326		args = append(args, opts.Host)
327	}
328
329	query := fmt.Sprintf(`
330		SELECT referer, sum(unique_visits) as total_visits
331		FROM analytics_monthly_top_referers
332		WHERE user_id = $1 AND month >= $2 AND month < $3 %s
333		GROUP BY referer
334		ORDER BY total_visits DESC
335		LIMIT 10`, where)
336
337	rows, err := me.Db.Queryx(query, args...)
338	if err != nil {
339		return nil, err
340	}
341	defer func() { _ = rows.Close() }()
342
343	var results []*db.VisitUrl
344	for rows.Next() {
345		result := &db.VisitUrl{}
346		if err := rows.Scan(&result.Url, &result.Count); err != nil {
347			return nil, err
348		}
349		results = append(results, result)
350	}
351	return results, rows.Err()
352}
353
354// visitRefererFromRaw reads top referers from analytics_visits for the previous and current months.
355func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
356	now := time.Now()
357	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
358	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
359
360	where, with := visitFilterBy(opts)
361
362	// Determine the effective start: max(origin, previousMonthStart)
363	effectiveStart := previousMonthStart
364	if opts.Origin.After(previousMonthStart) {
365		effectiveStart = opts.Origin
366	}
367
368	topUrls := fmt.Sprintf(`
369		SELECT
370			referer,
371			count(DISTINCT ip_address) as referer_count
372		FROM analytics_visits
373		WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404
374		GROUP BY referer
375		ORDER BY referer_count DESC
376		LIMIT 10`, where)
377
378	rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
379	if err != nil {
380		return nil, err
381	}
382	defer func() { _ = rows.Close() }()
383
384	var results []*db.VisitUrl
385	for rows.Next() {
386		result := &db.VisitUrl{}
387		if err := rows.Scan(&result.Url, &result.Count); err != nil {
388			return nil, err
389		}
390		results = append(results, result)
391	}
392	return results, rows.Err()
393}
394
395func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
396	var historical, current []*db.VisitUrl
397	var histErr, rawErr error
398
399	var wg sync.WaitGroup
400	wg.Add(2)
401
402	go func() {
403		defer wg.Done()
404		historical, histErr = me.visitUrlFromSummary(opts)
405	}()
406	go func() {
407		defer wg.Done()
408		current, rawErr = me.visitUrlFromRaw(opts)
409	}()
410
411	wg.Wait()
412
413	if histErr != nil {
414		return nil, fmt.Errorf("query summary urls: %w", histErr)
415	}
416	if rawErr != nil {
417		return nil, fmt.Errorf("query raw urls: %w", rawErr)
418	}
419
420	return mergeTopUrls(historical, current), nil
421}
422
423// visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data.
424func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
425	now := time.Now()
426	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
427	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
428
429	// If origin is in the previous month or later, raw data covers it — no summary to fetch.
430	if !opts.Origin.Before(previousMonthStart) {
431		return nil, nil
432	}
433
434	// Clamp origin to month boundary for summary table lookup
435	originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
436
437	where := ""
438	args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
439	if opts.Host != "" {
440		where = "AND host = $4"
441		args = append(args, opts.Host)
442	}
443
444	query := fmt.Sprintf(`
445		SELECT path, sum(unique_visits) as total_visits
446		FROM analytics_monthly_top_urls
447		WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s
448		GROUP BY path
449		ORDER BY total_visits DESC
450		LIMIT 10`, where)
451
452	rows, err := me.Db.Queryx(query, args...)
453	if err != nil {
454		return nil, err
455	}
456	defer func() { _ = rows.Close() }()
457
458	var results []*db.VisitUrl
459	for rows.Next() {
460		result := &db.VisitUrl{}
461		if err := rows.Scan(&result.Url, &result.Count); err != nil {
462			return nil, err
463		}
464		results = append(results, result)
465	}
466	return results, rows.Err()
467}
468
469// visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months.
470func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
471	now := time.Now()
472	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
473	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
474
475	where, with := visitFilterBy(opts)
476
477	// Determine the effective start: max(origin, previousMonthStart)
478	effectiveStart := previousMonthStart
479	if opts.Origin.After(previousMonthStart) {
480		effectiveStart = opts.Origin
481	}
482
483	topUrls := fmt.Sprintf(`
484		SELECT
485			path,
486			count(DISTINCT ip_address) as path_count
487		FROM analytics_visits
488		WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404
489		GROUP BY path
490		ORDER BY path_count DESC
491		LIMIT 10`, where)
492
493	rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
494	if err != nil {
495		return nil, err
496	}
497	defer func() { _ = rows.Close() }()
498
499	var results []*db.VisitUrl
500	for rows.Next() {
501		result := &db.VisitUrl{}
502		if err := rows.Scan(&result.Url, &result.Count); err != nil {
503			return nil, err
504		}
505		results = append(results, result)
506	}
507	return results, rows.Err()
508}
509
510func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
511	limit := opts.Limit
512	if limit == 0 {
513		limit = 10
514	}
515
516	var historical, current []*db.VisitUrl
517	var histErr, rawErr error
518
519	var wg sync.WaitGroup
520	wg.Add(2)
521
522	go func() {
523		defer wg.Done()
524		historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit)
525	}()
526	go func() {
527		defer wg.Done()
528		current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit)
529	}()
530
531	wg.Wait()
532
533	if histErr != nil {
534		return nil, fmt.Errorf("query summary 404 urls: %w", histErr)
535	}
536	if rawErr != nil {
537		return nil, fmt.Errorf("query raw 404 urls: %w", rawErr)
538	}
539
540	return mergeTopUrls(historical, current), nil
541}
542
543// visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data.
544func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
545	now := time.Now()
546	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
547	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
548
549	// If origin is in the previous month or later, raw data covers it — no summary to fetch.
550	if !opts.Origin.Before(previousMonthStart) {
551		return nil, nil
552	}
553
554	// Clamp origin to month boundary for summary table lookup
555	originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
556
557	where := ""
558	args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
559	argIdx := 4
560	if opts.Host != "" {
561		where = "AND host = $" + fmt.Sprintf("%d", argIdx)
562		args = append(args, opts.Host)
563	}
564
565	query := fmt.Sprintf(`
566		SELECT path, sum(unique_visits) as total_visits
567		FROM analytics_monthly_top_urls
568		WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s
569		GROUP BY path
570		ORDER BY total_visits DESC
571		LIMIT %d`, where, limit)
572
573	rows, err := me.Db.Queryx(query, args...)
574	if err != nil {
575		return nil, err
576	}
577	defer func() { _ = rows.Close() }()
578
579	var results []*db.VisitUrl
580	for rows.Next() {
581		result := &db.VisitUrl{}
582		if err := rows.Scan(&result.Url, &result.Count); err != nil {
583			return nil, err
584		}
585		results = append(results, result)
586	}
587	return results, rows.Err()
588}
589
590// visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months.
591func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
592	now := time.Now()
593	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
594	previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
595
596	where, with := visitFilterBy(opts)
597
598	// Determine the effective start: max(origin, previousMonthStart)
599	effectiveStart := previousMonthStart
600	if opts.Origin.After(previousMonthStart) {
601		effectiveStart = opts.Origin
602	}
603
604	topUrls := fmt.Sprintf(`
605		SELECT
606			path,
607			count(DISTINCT ip_address) as path_count
608		FROM analytics_visits
609		WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404
610		GROUP BY path
611		ORDER BY path_count DESC
612		LIMIT %d`, where, limit)
613
614	rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
615	if err != nil {
616		return nil, err
617	}
618	defer func() { _ = rows.Close() }()
619
620	var results []*db.VisitUrl
621	for rows.Next() {
622		result := &db.VisitUrl{}
623		if err := rows.Scan(&result.Url, &result.Count); err != nil {
624			return nil, err
625		}
626		results = append(results, result)
627	}
628	return results, rows.Err()
629}
630
631func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
632	var historical, current []*db.VisitUrl
633	var histErr, rawErr error
634
635	var wg sync.WaitGroup
636	wg.Add(2)
637
638	go func() {
639		defer wg.Done()
640		historical, histErr = me.visitHostFromSummary(opts)
641	}()
642	go func() {
643		defer wg.Done()
644		current, rawErr = me.visitHostFromRaw(opts)
645	}()
646
647	wg.Wait()
648
649	if histErr != nil {
650		return nil, fmt.Errorf("query summary hosts: %w", histErr)
651	}
652	if rawErr != nil {
653		return nil, fmt.Errorf("query raw hosts: %w", rawErr)
654	}
655
656	return mergeHosts(historical, current), nil
657}
658
659// visitHostFromSummary reads host data from analytics_user_sites for historical data.
660func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
661	rows, err := me.Db.Queryx(`
662		SELECT host, total_visits
663		FROM analytics_user_sites
664		WHERE user_id = $1 AND host <> ''
665		ORDER BY total_visits DESC`, opts.UserID)
666	if err != nil {
667		return nil, err
668	}
669	defer func() { _ = rows.Close() }()
670
671	var results []*db.VisitUrl
672	for rows.Next() {
673		result := &db.VisitUrl{}
674		if err := rows.Scan(&result.Url, &result.Count); err != nil {
675			return nil, err
676		}
677		results = append(results, result)
678	}
679	return results, rows.Err()
680}
681
682// visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary.
683func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
684	now := time.Now()
685	currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
686
687	rows, err := me.Db.Queryx(`
688		SELECT host, count(DISTINCT ip_address) as host_count
689		FROM analytics_visits
690		WHERE created_at >= $1 AND user_id = $2 AND host <> ''
691		GROUP BY host
692		ORDER BY host_count DESC`, currentMonthStart, opts.UserID)
693	if err != nil {
694		return nil, err
695	}
696	defer func() { _ = rows.Close() }()
697
698	var results []*db.VisitUrl
699	for rows.Next() {
700		result := &db.VisitUrl{}
701		if err := rows.Scan(&result.Url, &result.Count); err != nil {
702			return nil, err
703		}
704		results = append(results, result)
705	}
706	return results, rows.Err()
707}
708
709func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
710	var (
711		visitors    []*db.VisitInterval
712		urls        []*db.VisitUrl
713		refs        []*db.VisitUrl
714		notFound    []*db.VisitUrl
715		visitorsErr error
716		urlsErr     error
717		refsErr     error
718		nfErr       error
719	)
720
721	var wg sync.WaitGroup
722	wg.Add(4)
723
724	go func() {
725		defer wg.Done()
726		visitors, visitorsErr = me.visitUnique(opts)
727	}()
728	go func() {
729		defer wg.Done()
730		urls, urlsErr = me.visitUrl(opts)
731	}()
732	go func() {
733		defer wg.Done()
734		refs, refsErr = me.visitReferer(opts)
735	}()
736	go func() {
737		defer wg.Done()
738		notFound, nfErr = me.VisitUrlNotFound(opts)
739	}()
740
741	wg.Wait()
742
743	// Return the first error encountered
744	for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} {
745		if err != nil {
746			return nil, err
747		}
748	}
749
750	return &db.SummaryVisits{
751		Intervals:    visitors,
752		TopUrls:      urls,
753		TopReferers:  refs,
754		NotFoundUrls: notFound,
755	}, nil
756}
757
758func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
759	return me.visitHost(opts)
760}