Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader: - story_views table + RecordStoryView on /api/article (background, filter-guarded); "Popular this week" home rail via TrendingStories; read-count badge and reading-time chip decorated onto every listing - reader: signed-in-only read-aloud (TTS), native share/copy, and an Aa typography popover (size/serif/sepia) persisted per device - real alt text on card/reader/related/search images; time-of-day Pete greeting on the home hero - harden exec() to skip (not panic) on a nil DB so background writes can't crash on a closed handle Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
This commit is contained in:
@@ -10,6 +10,10 @@ const secondsPerDay = 86400
|
||||
// unixDay returns the current UTC day number (floor(unix / 86400)).
|
||||
func unixDay() int64 { return nowUnix() / secondsPerDay }
|
||||
|
||||
// UnixDay is the exported current UTC day number, for callers building
|
||||
// day-windowed queries (e.g. the trending rail's 7-day cutoff).
|
||||
func UnixDay() int64 { return unixDay() }
|
||||
|
||||
// RecordPageView increments the all-time and per-day view counter for a coarse
|
||||
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
|
||||
// never affect serving a page, so errors are logged and swallowed.
|
||||
@@ -28,6 +32,61 @@ func RecordVisitor(token string) {
|
||||
unixDay(), token)
|
||||
}
|
||||
|
||||
// RecordStoryView bumps the per-day read counter for a single story. Called
|
||||
// when a visitor opens the story in reader mode. Fire-and-forget like the other
|
||||
// metrics writes: a failure here must never affect serving the article.
|
||||
func RecordStoryView(id int64) {
|
||||
exec("record story view",
|
||||
`INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`,
|
||||
id, unixDay())
|
||||
}
|
||||
|
||||
// StoryViewTotals returns all-time read counts for the given story ids, as a
|
||||
// map keyed by id. Ids with no recorded views are simply absent from the map
|
||||
// (callers treat missing as zero). Best-effort: on error it returns whatever it
|
||||
// managed to read, so a metrics hiccup never blanks a page.
|
||||
func StoryViewTotals(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT story_id, SUM(views) FROM story_views
|
||||
WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...)
|
||||
if err != nil {
|
||||
slog.Error("metrics: story view totals query failed", "err", err)
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var n int
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
slog.Error("metrics: scan story view total failed", "err", err)
|
||||
continue
|
||||
}
|
||||
out[id] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intInClause builds a "?, ?, …" placeholder string and matching args slice for
|
||||
// a SQL IN (…) over int64 ids.
|
||||
func intInClause(ids []int64) (string, []any) {
|
||||
ph := make([]byte, 0, len(ids)*2)
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
ph = append(ph, ',')
|
||||
}
|
||||
ph = append(ph, '?')
|
||||
args[i] = id
|
||||
}
|
||||
return string(ph), args
|
||||
}
|
||||
|
||||
// PathStat is one row of the per-page usage breakdown.
|
||||
type PathStat struct {
|
||||
Path string
|
||||
|
||||
Reference in New Issue
Block a user