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:
@@ -27,6 +27,8 @@ type StoryView struct {
|
||||
Posted bool
|
||||
Paywalled bool // source is gated and no archive workaround succeeded
|
||||
Channel string // channel slug; also the theme key
|
||||
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
|
||||
Views int // all-time reader-mode opens; 0 = none yet (no badge)
|
||||
}
|
||||
|
||||
func toView(s storage.Story) StoryView {
|
||||
@@ -44,6 +46,49 @@ func toView(s storage.Story) StoryView {
|
||||
}
|
||||
}
|
||||
|
||||
// decorate fills the ReadMins and Views fields on one or more groups of story
|
||||
// cards using two cheap batch queries over the whole id set. Called just before
|
||||
// render so every listing (home rails, channel pages, bookmarks, for-you) shows
|
||||
// a reading-time chip and a read count without each list query having to carry
|
||||
// those columns. Best-effort: a metrics hiccup just leaves the badges off.
|
||||
func decorate(groups ...[]StoryView) {
|
||||
var ids []int64
|
||||
seen := make(map[int64]bool)
|
||||
for _, g := range groups {
|
||||
for _, v := range g {
|
||||
if v.ID > 0 && !seen[v.ID] {
|
||||
seen[v.ID] = true
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
lengths := storage.StoryContentLengths(ids)
|
||||
views := storage.StoryViewTotals(ids)
|
||||
for _, g := range groups {
|
||||
for i := range g {
|
||||
g[i].ReadMins = readMinutes(lengths[g[i].ID])
|
||||
g[i].Views = views[g[i].ID]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readMinutes turns a character count into a rounded minutes-to-read estimate,
|
||||
// assuming ~200 wpm and ~6 characters per word (5-letter words plus a space).
|
||||
// Any non-empty body reads as at least "1 min".
|
||||
func readMinutes(chars int) int {
|
||||
if chars <= 0 {
|
||||
return 0
|
||||
}
|
||||
mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars)
|
||||
if mins < 1 {
|
||||
mins = 1
|
||||
}
|
||||
return mins
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
SiteTitle string
|
||||
Channels []Channel
|
||||
@@ -79,6 +124,7 @@ type indexPage struct {
|
||||
Stats []channelStat
|
||||
Latest []StoryView
|
||||
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
||||
Trending []StoryView // most-read this week; empty until reads accumulate
|
||||
}
|
||||
|
||||
type channelStat struct {
|
||||
@@ -138,6 +184,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
const (
|
||||
justPostedLimit = 6
|
||||
latestLimit = 16
|
||||
trendingLimit = 8
|
||||
)
|
||||
|
||||
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
||||
@@ -179,11 +226,23 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
|
||||
// Popular this week: most-read stories over the trailing 7 UTC days. Empty
|
||||
// until reads accumulate, so the section simply doesn't render on a fresh DB.
|
||||
var trending []StoryView
|
||||
if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil {
|
||||
slog.Error("web: trending query failed", "err", err)
|
||||
} else {
|
||||
for _, row := range trendRows {
|
||||
trending = append(trending, toView(row))
|
||||
}
|
||||
}
|
||||
|
||||
data := indexPage{
|
||||
pageData: s.base(r),
|
||||
JustPosted: justPosted,
|
||||
Stats: stats,
|
||||
Latest: latest,
|
||||
Trending: trending,
|
||||
}
|
||||
// For signed-in users with some read/bookmark history, lead with a small
|
||||
// personalized rail. ForYou returns nothing for anon / no-history users, so
|
||||
@@ -200,6 +259,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
|
||||
s.render(w, "index", data)
|
||||
}
|
||||
|
||||
@@ -228,6 +288,7 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range rows {
|
||||
views = append(views, toView(row))
|
||||
}
|
||||
decorate(views)
|
||||
base := s.base(r)
|
||||
base.Active = "for-you"
|
||||
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
|
||||
@@ -257,6 +318,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
views = append(views, toView(r))
|
||||
}
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
decorate(views)
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = ch.Slug
|
||||
@@ -315,6 +377,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range rows {
|
||||
views = append(views, toView(row))
|
||||
}
|
||||
decorate(views)
|
||||
total, _ := storage.CountBookmarks(u.Sub)
|
||||
|
||||
base := s.base(r)
|
||||
@@ -519,6 +582,11 @@ func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Opening a story in reader mode is our per-story read signal. found==true
|
||||
// means the id passed the same visibility filter the listings use, so this
|
||||
// can't be driven to inflate counts for hidden/unclassified stories. Fired
|
||||
// in the background so serving the body never waits on the write.
|
||||
go storage.RecordStoryView(id)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user