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

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