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:
prosolis
2026-07-07 22:17:23 -07:00
parent 2ea5f7a6f7
commit 616055a704
14 changed files with 737 additions and 12 deletions

View File

@@ -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