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

@@ -125,6 +125,10 @@ func RunMaintenance() {
exec("prune orphan user_story_state",
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
// Same for per-story view counts once their story has aged out.
exec("prune orphan story_views",
`DELETE FROM story_views WHERE story_id NOT IN (SELECT id FROM stories)`)
// Daily unique tokens are only useful for the recent window; their salts are
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
exec("prune old daily_visitors",
@@ -134,9 +138,18 @@ func RunMaintenance() {
exec("optimize", "PRAGMA optimize")
}
// exec is a fire-and-forget helper that logs errors.
// exec is a fire-and-forget helper that logs errors. Several callers run it from
// background goroutines (metrics, view counts), which can outlive a Close() — so
// unlike Get() it must not panic on a nil handle: it simply skips the write.
func exec(label, query string, args ...any) {
if _, err := Get().Exec(query, args...); err != nil {
mu.RLock()
db := globalDB
mu.RUnlock()
if db == nil {
slog.Warn("db exec skipped: no database", "op", label)
return
}
if _, err := db.Exec(query, args...); err != nil {
slog.Error("db exec failed", "op", label, "err", err)
}
}

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

View File

@@ -380,6 +380,67 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
return out, rows.Err()
}
// TrendingStories returns the most-read classified stories (real channels only)
// counting views recorded on or after sinceDay (a unix day number), newest-ish
// as a tiebreak. Used for the home page's "popular this week" rail. Stories with
// no views in the window don't appear, so the rail is empty until reads exist.
func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
rows, err := Get().Query(
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
FROM stories s
JOIN story_views v ON v.story_id = s.id
WHERE s.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
AND v.day >= ?
GROUP BY s.id
ORDER BY SUM(v.views) DESC, COALESCE(s.published_at, s.seen_at) DESC
LIMIT ?`, sinceDay, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// StoryContentLengths returns the captured article text length (in characters)
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
// chip without transferring the full body: only LENGTH(content) crosses the
// wire. Ids with no captured content are absent (treated as zero by callers).
func StoryContentLengths(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 id, LENGTH(content) FROM stories
WHERE id IN (`+ph+`) AND content IS NOT NULL AND content <> ''`, args...)
if err != nil {
slog.Error("story content lengths query failed", "err", err)
return out
}
defer rows.Close()
for rows.Next() {
var id, n int64
if err := rows.Scan(&id, &n); err != nil {
slog.Error("scan content length failed", "err", err)
continue
}
out[id] = int(n)
}
return out
}
// CountClassifiedByChannel returns how many classified stories exist for a channel.
func CountClassifiedByChannel(channel string) (int, error) {
var n int

View File

@@ -115,6 +115,18 @@ CREATE TABLE IF NOT EXISTS daily_visitors (
PRIMARY KEY (day, visitor)
);
-- Per-story read counts, keyed by story id and UTC day. Incremented whenever a
-- visitor opens a story in reader mode (/api/article). The day dimension lets
-- us surface "popular this week" without a separate rollup; summing across all
-- days gives the all-time count shown on cards. Rows age out with their story
-- via the foreign-key-less prune in RunMaintenance.
CREATE TABLE IF NOT EXISTS story_views (
story_id INTEGER NOT NULL,
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (story_id, day)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
@@ -129,6 +141,7 @@ CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day);
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);

View File

@@ -0,0 +1,91 @@
package storage
import "testing"
// insertClassified is a tiny helper to seed a visible story and return its id.
func insertClassified(t *testing.T, guid, headline, content string) int64 {
t.Helper()
s := &Story{
GUID: guid,
Headline: headline,
Content: content,
ArticleURL: "https://example.com/" + guid,
Source: "Example Wire",
Channel: "tech",
Classified: true,
SeenAt: nowUnix(),
}
if err := InsertStory(s); err != nil {
t.Fatalf("insert %s: %v", guid, err)
}
var id int64
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
t.Fatalf("lookup %s: %v", guid, err)
}
return id
}
func TestStoryViews_TotalsAndTrending(t *testing.T) {
setupTestDB(t)
a := insertClassified(t, "s-a", "Story A", "some body text here")
b := insertClassified(t, "s-b", "Story B", "")
c := insertClassified(t, "s-c", "Story C", "another body")
// A read three times, C twice, B never.
RecordStoryView(a)
RecordStoryView(a)
RecordStoryView(a)
RecordStoryView(c)
RecordStoryView(c)
totals := StoryViewTotals([]int64{a, b, c})
if totals[a] != 3 {
t.Errorf("totals[a] = %d, want 3", totals[a])
}
if _, ok := totals[b]; ok {
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
}
if totals[c] != 2 {
t.Errorf("totals[c] = %d, want 2", totals[c])
}
// Trending over the last week: A (3) before C (2); B is absent (no views).
trend, err := TrendingStories(10, unixDay()-6)
if err != nil {
t.Fatalf("trending: %v", err)
}
if len(trend) != 2 {
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
}
if trend[0].ID != a || trend[1].ID != c {
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
}
// A window that starts after today excludes everything.
future, err := TrendingStories(10, unixDay()+1)
if err != nil {
t.Fatalf("trending future: %v", err)
}
if len(future) != 0 {
t.Errorf("trending (future window) len = %d, want 0", len(future))
}
}
func TestStoryContentLengths(t *testing.T) {
setupTestDB(t)
a := insertClassified(t, "c-a", "Has body", "hello world body")
b := insertClassified(t, "c-b", "No body", "")
lengths := StoryContentLengths([]int64{a, b})
if lengths[a] != len("hello world body") {
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
}
if _, ok := lengths[b]; ok {
t.Errorf("lengths[b] present, want absent (empty content)")
}
if got := StoryContentLengths(nil); len(got) != 0 {
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
}
}