package store import ( "fmt" "time" ) func (s *Store) LogView(slug, visitor, referrer string, at time.Time) error { var ref any if referrer != "" { ref = referrer } _, err := s.DB.Exec(` INSERT INTO views (slug, viewed_at, visitor, referrer) VALUES (?, ?, ?, ?) `, slug, at.Unix(), visitor, ref) return err } func (s *Store) LogUnlockAttempt(slug, visitor string, success bool, at time.Time) error { v := 0 if success { v = 1 } _, err := s.DB.Exec(` INSERT INTO unlock_attempts (slug, at, visitor, success) VALUES (?, ?, ?, ?) `, slug, at.Unix(), visitor, v) return err } // RecentFailedUnlocks counts failed unlock attempts for (slug, visitor) within the window. func (s *Store) RecentFailedUnlocks(slug, visitor string, since time.Time) (int, error) { var n int err := s.DB.QueryRow(` SELECT COUNT(*) FROM unlock_attempts WHERE slug = ? AND visitor = ? AND success = 0 AND at >= ? `, slug, visitor, since.Unix()).Scan(&n) return n, err } // Rollup merges "views" rows from a given day into daily_stats. Idempotent: replaces existing rows for that day. func (s *Store) Rollup(day time.Time) error { dayStr := day.Format("2006-01-02") start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() end := start + 86400 tx, err := s.DB.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM daily_stats WHERE day = ?`, dayStr); err != nil { return err } _, err = tx.Exec(` INSERT INTO daily_stats (slug, day, views, uniques) SELECT slug, ?, COUNT(*), COUNT(DISTINCT visitor) FROM views WHERE viewed_at >= ? AND viewed_at < ? GROUP BY slug `, dayStr, start, end) if err != nil { return err } return tx.Commit() } // PruneRawViews removes view rows older than `keep` days. func (s *Store) PruneRawViews(keep int) error { cutoff := time.Now().AddDate(0, 0, -keep).Unix() _, err := s.DB.Exec(`DELETE FROM views WHERE viewed_at < ?`, cutoff) return err } type DashboardStats struct { TotalArtifacts int64 Views7d int64 Views30d int64 StorageBytes int64 } func (s *Store) Dashboard() (DashboardStats, error) { var st DashboardStats if err := s.DB.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM artifacts`). Scan(&st.TotalArtifacts, &st.StorageBytes); err != nil { return st, err } since7 := time.Now().AddDate(0, 0, -7).Unix() since30 := time.Now().AddDate(0, 0, -30).Unix() // Combine recent "views" table + daily_stats for dates covered by either. if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since7).Scan(&st.Views7d); err != nil { return st, err } var fromRollup int64 if err := s.DB.QueryRow(` SELECT COALESCE(SUM(views),0) FROM daily_stats WHERE day >= ? `, time.Now().AddDate(0, 0, -30).Format("2006-01-02")).Scan(&fromRollup); err != nil { return st, err } var fromRaw int64 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since30).Scan(&fromRaw); err != nil { return st, err } // For 30d, prefer raw if rollup is empty (fresh install); they should mostly match since Rollup prunes raw after 30d. if fromRaw > fromRollup { st.Views30d = fromRaw } else { st.Views30d = fromRollup } return st, nil } // sparkline returns last N days of view counts (oldest → newest). Uses daily_stats then patches today from raw views. func (s *Store) sparkline(slug string, days int) ([]int64, error) { out := make([]int64, days) today := time.Now() for i := 0; i < days; i++ { day := today.AddDate(0, 0, -(days - 1 - i)) dayStr := day.Format("2006-01-02") var v int64 err := s.DB.QueryRow(`SELECT COALESCE(views,0) FROM daily_stats WHERE slug = ? AND day = ?`, slug, dayStr).Scan(&v) if err != nil && err.Error() != "sql: no rows in result set" { return nil, err } // Patch with raw views if rollup hasn't happened yet for this day (mainly: today). start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() end := start + 86400 var raw int64 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ?`, slug, start, end).Scan(&raw); err != nil { return nil, err } if raw > v { v = raw } out[i] = v } return out, nil } type DayPoint struct { Day string Views int64 Unique int64 } // SeriesFor returns a 30-day series for a single artifact, oldest → newest. func (s *Store) SeriesFor(slug string, days int) ([]DayPoint, error) { out := make([]DayPoint, days) today := time.Now() for i := 0; i < days; i++ { day := today.AddDate(0, 0, -(days - 1 - i)) dayStr := day.Format("2006-01-02") var dp DayPoint dp.Day = dayStr var v, u int64 if err := s.DB.QueryRow(` SELECT COALESCE(views,0), COALESCE(uniques,0) FROM daily_stats WHERE slug = ? AND day = ? `, slug, dayStr).Scan(&v, &u); err != nil && err.Error() != "sql: no rows in result set" { return nil, err } start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() end := start + 86400 var raw, rawU int64 if err := s.DB.QueryRow(` SELECT COUNT(*), COUNT(DISTINCT visitor) FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ? `, slug, start, end).Scan(&raw, &rawU); err != nil { return nil, err } if raw > v { v = raw } if rawU > u { u = rawU } dp.Views = v dp.Unique = u out[i] = dp } return out, nil } type ReferrerCount struct { Host string Count int64 } func (s *Store) TopReferrers(slug string, limit int) ([]ReferrerCount, error) { rows, err := s.DB.Query(` SELECT COALESCE(referrer, ''), COUNT(*) as n FROM views WHERE slug = ? GROUP BY referrer ORDER BY n DESC LIMIT ? `, slug, limit) if err != nil { return nil, err } defer rows.Close() var out []ReferrerCount for rows.Next() { var r ReferrerCount if err := rows.Scan(&r.Host, &r.Count); err != nil { return nil, err } out = append(out, r) } return out, nil } // Debug helper used in smoke tests. func (s *Store) Stringer() string { return fmt.Sprintf("store(%s)", s.DataDir) }