nandi/gleanpublic Fork 0
465d821
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Implement recommendation caching and unify dashboard UI/UXUnverified

Julien Robert committed 2026-05-10T23:15:56+02:00 Browse files
465d821 parent: 0b50627
modified docs/specs.md +3 -3
@@ -765,7 +765,7 @@ New users with <5 subscriptions get a fallback strategy:
765765
766766 ### 7.10 Clustering Engine (Cron)
767767
768-A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):
768+A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 1h):
769769
770770 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured)
771771 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
@@ -907,7 +907,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
907907 | Route | Method | Description |
908908 | ------------------------------ | ------ | ------------------------------------------------------------------- |
909909 | `/` | GET | Landing page / auth redirect |
910-| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar |
910+| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds |
911911 | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |
912912 | `/feeds/list` | GET | Feed list fragment (htmx partial) |
913913 | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) |
@@ -1092,7 +1092,7 @@ Browser ──GET /articles──► Server
10921092 ### 11.3 Recommendations
10931093
10941094 ```
1095-Cron (every 10m) ──► Cluster Engine
1095+Cron (every 1h) ──► Cluster Engine
10961096
10971097 ├─► Compute feed similarity
10981098 ├─► Compute user similarity
@@ -765,7 +765,7 @@ New users with <5 subscriptions get a fallback strategy:
765 765
766 ### 7.10 Clustering Engine (Cron)766 ### 7.10 Clustering Engine (Cron)
767 767
768-A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):768+A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 1h):
769 769
770 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured)770 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured)
771 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)771 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
@@ -907,7 +907,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
907 | Route | Method | Description |907 | Route | Method | Description |
908 | ------------------------------ | ------ | ------------------------------------------------------------------- |908 | ------------------------------ | ------ | ------------------------------------------------------------------- |
909 | `/` | GET | Landing page / auth redirect |909 | `/` | GET | Landing page / auth redirect |
910-| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar |910+| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds |
911 | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |911 | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |
912 | `/feeds/list` | GET | Feed list fragment (htmx partial) |912 | `/feeds/list` | GET | Feed list fragment (htmx partial) |
913 | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) |913 | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) |
@@ -1092,7 +1092,7 @@ Browser ──GET /articles──► Server
1092 ### 11.3 Recommendations1092 ### 11.3 Recommendations
1093 1093
1094 ```1094 ```
1095-Cron (every 10m) ──► Cluster Engine1095+Cron (every 1h) ──► Cluster Engine
1096 1096
1097 ├─► Compute feed similarity1097 ├─► Compute feed similarity
1098 ├─► Compute user similarity1098 ├─► Compute user similarity
modified go.mod +1 -1
@@ -8,6 +8,7 @@ require (
88 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
99 github.com/go-chi/chi/v5 v5.2.5
1010 github.com/go-chi/cors v1.2.2
11+ github.com/hashicorp/golang-lru/v2 v2.0.7
1112 github.com/mattn/go-sqlite3 v1.14.22
1213 github.com/openai/openai-go v1.12.0
1314 github.com/prometheus/client_golang v1.19.1
@@ -27,7 +28,6 @@ require (
2728 github.com/google/go-cmp v0.6.0 // indirect
2829 github.com/google/go-querystring v1.1.0 // indirect
2930 github.com/gorilla/websocket v1.5.3 // indirect
30- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
3131 github.com/ipfs/go-cid v0.4.1 // indirect
3232 github.com/klauspost/compress v1.17.9 // indirect
3333 github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -8,6 +8,7 @@ require (
8 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda288 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
9 github.com/go-chi/chi/v5 v5.2.59 github.com/go-chi/chi/v5 v5.2.5
10 github.com/go-chi/cors v1.2.210 github.com/go-chi/cors v1.2.2
11+ github.com/hashicorp/golang-lru/v2 v2.0.7
11 github.com/mattn/go-sqlite3 v1.14.2212 github.com/mattn/go-sqlite3 v1.14.22
12 github.com/openai/openai-go v1.12.013 github.com/openai/openai-go v1.12.0
13 github.com/prometheus/client_golang v1.19.114 github.com/prometheus/client_golang v1.19.1
@@ -27,7 +28,6 @@ require (
27 github.com/google/go-cmp v0.6.0 // indirect28 github.com/google/go-cmp v0.6.0 // indirect
28 github.com/google/go-querystring v1.1.0 // indirect29 github.com/google/go-querystring v1.1.0 // indirect
29 github.com/gorilla/websocket v1.5.3 // indirect30 github.com/gorilla/websocket v1.5.3 // indirect
30- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
31 github.com/ipfs/go-cid v0.4.1 // indirect31 github.com/ipfs/go-cid v0.4.1 // indirect
32 github.com/klauspost/compress v1.17.9 // indirect32 github.com/klauspost/compress v1.17.9 // indirect
33 github.com/klauspost/cpuid/v2 v2.2.7 // indirect33 github.com/klauspost/cpuid/v2 v2.2.7 // indirect
modified internal/cluster/cron.go +6 -0
@@ -32,6 +32,12 @@ func (c *Cron) Run(ctx context.Context) error {
3232 if !c.engine.mu.TryLock() {
3333 c.logger.Info("skipping computation: already in progress")
3434 } else {
35+ c.engine.feedCache.Purge()
36+ c.engine.peopleCache.Purge()
37+ c.engine.articleCache.Purge()
38+ c.engine.globalTrendingCache.Purge()
39+ c.engine.personalTrendingCache.Purge()
40+
3541 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
3642 c.engine.logger.Error("feed embeddings failed", "error", err)
3743 }
@@ -32,6 +32,12 @@ func (c *Cron) Run(ctx context.Context) error {
32 if !c.engine.mu.TryLock() {32 if !c.engine.mu.TryLock() {
33 c.logger.Info("skipping computation: already in progress")33 c.logger.Info("skipping computation: already in progress")
34 } else {34 } else {
35+ c.engine.feedCache.Purge()
36+ c.engine.peopleCache.Purge()
37+ c.engine.articleCache.Purge()
38+ c.engine.globalTrendingCache.Purge()
39+ c.engine.personalTrendingCache.Purge()
40+
35 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {41 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
36 c.engine.logger.Error("feed embeddings failed", "error", err)42 c.engine.logger.Error("feed embeddings failed", "error", err)
37 }43 }
modified internal/cluster/jaccard.go +32 -9
@@ -6,6 +6,11 @@ import (
66 "fmt"
77 "log/slog"
88 "sync"
9+ "time"
10+
11+ "github.com/hashicorp/golang-lru/v2/expirable"
12+
13+ "pkg.rbrt.fr/glean/internal/db"
914 )
1015
1116 // Config controls weights used during similarity computation (feed similarity
@@ -33,20 +38,38 @@ func DefaultConfig() Config {
3338 // All public methods are safe for concurrent use (cron writes, on-demand reads).
3439 type Engine struct {
3540 db *sql.DB
36- logger *slog.Logger
37- mu sync.Mutex
38- config Config
41+ articles *db.ArticleStore
42+
43+ logger *slog.Logger
44+ mu sync.Mutex
45+ config Config
46+
3947 embedder Embedder
4048 llm *LLMClient
49+
50+ feedCache *expirable.LRU[string, []*FeedRecommendation]
51+ peopleCache *expirable.LRU[string, []*PersonRecommendation]
52+ articleCache *expirable.LRU[string, []*ArticleRecommendation]
53+ globalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
54+ personalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
4155 }
4256
43-func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine {
57+// recCacheSize is the maximum number of recommendations to cache per user.
58+const recCacheSize = 512
59+
60+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
4461 return &Engine{
45- db: db,
46- logger: logger,
47- config: DefaultConfig(),
48- embedder: embedder,
49- llm: llm,
62+ db: sqlDB,
63+ articles: articles,
64+ logger: logger,
65+ config: config,
66+ embedder: embedder,
67+ llm: llm,
68+ feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
69+ peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
70+ articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
71+ globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
72+ personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
5073 }
5174 }
5275
@@ -6,6 +6,11 @@ import (
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8 "sync"8 "sync"
9+ "time"
10+
11+ "github.com/hashicorp/golang-lru/v2/expirable"
12+
13+ "pkg.rbrt.fr/glean/internal/db"
9 )14 )
10 15
11 // Config controls weights used during similarity computation (feed similarity16 // Config controls weights used during similarity computation (feed similarity
@@ -33,20 +38,38 @@ func DefaultConfig() Config {
33 // All public methods are safe for concurrent use (cron writes, on-demand reads).38 // All public methods are safe for concurrent use (cron writes, on-demand reads).
34 type Engine struct {39 type Engine struct {
35 db *sql.DB40 db *sql.DB
36- logger *slog.Logger41+ articles *db.ArticleStore
37- mu sync.Mutex42+
38- config Config43+ logger *slog.Logger
44+ mu sync.Mutex
45+ config Config
46+
39 embedder Embedder47 embedder Embedder
40 llm *LLMClient48 llm *LLMClient
49+
50+ feedCache *expirable.LRU[string, []*FeedRecommendation]
51+ peopleCache *expirable.LRU[string, []*PersonRecommendation]
52+ articleCache *expirable.LRU[string, []*ArticleRecommendation]
53+ globalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
54+ personalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
41 }55 }
42 56
43-func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine {57+// recCacheSize is the maximum number of recommendations to cache per user.
58+const recCacheSize = 512
59+
60+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
44 return &Engine{61 return &Engine{
45- db: db,62+ db: sqlDB,
46- logger: logger,63+ articles: articles,
47- config: DefaultConfig(),64+ logger: logger,
48- embedder: embedder,65+ config: config,
49- llm: llm,66+ embedder: embedder,
67+ llm: llm,
68+ feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
69+ peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
70+ articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
71+ globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
72+ personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
50 }73 }
51 }74 }
52 75
modified internal/cluster/jaccard_test.go +2 -1
@@ -8,6 +8,7 @@ import (
88 "os"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
1314 "pkg.rbrt.fr/glean/internal/db"
@@ -94,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
9495 }
9596
9697 func newTestEngine(dbs *db.Store) *Engine {
97- return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default())
98+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig())
9899 }
99100
100101 func TestComputeFeedSimilarity(t *testing.T) {
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "strings"9 "strings"
10 "testing"10 "testing"
11+ "time"
11 12
12 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"13 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
13 "pkg.rbrt.fr/glean/internal/db"14 "pkg.rbrt.fr/glean/internal/db"
@@ -94,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
94 }95 }
95 96
96 func newTestEngine(dbs *db.Store) *Engine {97 func newTestEngine(dbs *db.Store) *Engine {
97- return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default())98+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig())
98 }99 }
99 100
100 func TestComputeFeedSimilarity(t *testing.T) {101 func TestComputeFeedSimilarity(t *testing.T) {
modified internal/cluster/scoring.go +59 -1
@@ -4,6 +4,8 @@ import (
44 "context"
55 "database/sql"
66 "fmt"
7+ "strings"
8+ "time"
79
810 "pkg.rbrt.fr/glean/internal/db"
911 )
@@ -49,6 +51,10 @@ type ArticleRecommendation struct {
4951 // KNN or graph+popular fallback). Results are min-max normalized and
5052 // diversity-filtered before returning.
5153 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
54+ if entry, ok := e.feedCache.Get(userDID); ok {
55+ return entry, nil
56+ }
57+
5258 subCount := 0
5359 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
5460
@@ -66,7 +72,9 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
6672 }
6773
6874 normalizeFeedScores(recs)
69- return ApplyDiversity(recs, limit), nil
75+ result := ApplyDiversity(recs, limit)
76+ e.feedCache.Add(userDID, result)
77+ return result, nil
7078 }
7179
7280 // GetPeopleRecommendations returns similar users based on subscription overlap,
@@ -74,6 +82,10 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
7482 // limit come from the user's network (followed) and half from outside. When
7583 // outside-network candidates are scarce, in-network fills the remaining slots.
7684 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
85+ if entry, ok := e.peopleCache.Get(userDID); ok {
86+ return entry, nil
87+ }
88+
7789 recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2)
7890 if err != nil {
7991 return nil, err
@@ -97,6 +109,7 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
97109 recs = append(recs, outNet[:outTake]...)
98110
99111 normalizePersonScores(recs)
112+ e.peopleCache.Add(userDID, recs)
100113 return recs, nil
101114 }
102115
@@ -105,14 +118,59 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
105118 // (embedding KNN against user's liked articles), and recency. Scores are
106119 // min-max normalized.
107120 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
121+ artKey := userDID + "|" + strings.Join(languages, ",")
122+ if entry, ok := e.articleCache.Get(artKey); ok {
123+ return entry, nil
124+ }
125+
108126 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
109127 if err != nil {
110128 return nil, err
111129 }
112130 normalizeArticleScores(recs)
131+ e.articleCache.Add(artKey, recs)
113132 return recs, nil
114133 }
115134
135+func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) {
136+ if offset == 0 {
137+ if entry, ok := e.globalTrendingCache.Get(userDID); ok {
138+ return entry, nil
139+ }
140+ }
141+
142+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
143+ items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)
144+ if err != nil {
145+ return nil, err
146+ }
147+
148+ if offset == 0 {
149+ e.globalTrendingCache.Add(userDID, items)
150+ }
151+ return items, nil
152+}
153+
154+func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) {
155+ key := userDID + "|" + strings.Join(languages, ",")
156+ if offset == 0 {
157+ if entry, ok := e.personalTrendingCache.Get(key); ok {
158+ return entry, nil
159+ }
160+ }
161+
162+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
163+ items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)
164+ if err != nil {
165+ return nil, err
166+ }
167+
168+ if offset == 0 {
169+ e.personalTrendingCache.Add(key, items)
170+ }
171+ return items, nil
172+}
173+
116174 // SignalWeights holds per-signal multipliers used in the recommendation scoring
117175 // formula. Weights are auto-tuned per user via a bandit-style reward/penalty
118176 // system (see weights.go). Each field maps to a column in
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "fmt"6 "fmt"
7+ "strings"
8+ "time"
7 9
8 "pkg.rbrt.fr/glean/internal/db"10 "pkg.rbrt.fr/glean/internal/db"
9 )11 )
@@ -49,6 +51,10 @@ type ArticleRecommendation struct {
49 // KNN or graph+popular fallback). Results are min-max normalized and51 // KNN or graph+popular fallback). Results are min-max normalized and
50 // diversity-filtered before returning.52 // diversity-filtered before returning.
51 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {53 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
54+ if entry, ok := e.feedCache.Get(userDID); ok {
55+ return entry, nil
56+ }
57+
52 subCount := 058 subCount := 0
53 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)59 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
54 60
@@ -66,7 +72,9 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
66 }72 }
67 73
68 normalizeFeedScores(recs)74 normalizeFeedScores(recs)
69- return ApplyDiversity(recs, limit), nil75+ result := ApplyDiversity(recs, limit)
76+ e.feedCache.Add(userDID, result)
77+ return result, nil
70 }78 }
71 79
72 // GetPeopleRecommendations returns similar users based on subscription overlap,80 // GetPeopleRecommendations returns similar users based on subscription overlap,
@@ -74,6 +82,10 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
74 // limit come from the user's network (followed) and half from outside. When82 // limit come from the user's network (followed) and half from outside. When
75 // outside-network candidates are scarce, in-network fills the remaining slots.83 // outside-network candidates are scarce, in-network fills the remaining slots.
76 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {84 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
85+ if entry, ok := e.peopleCache.Get(userDID); ok {
86+ return entry, nil
87+ }
88+
77 recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2)89 recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2)
78 if err != nil {90 if err != nil {
79 return nil, err91 return nil, err
@@ -97,6 +109,7 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
97 recs = append(recs, outNet[:outTake]...)109 recs = append(recs, outNet[:outTake]...)
98 110
99 normalizePersonScores(recs)111 normalizePersonScores(recs)
112+ e.peopleCache.Add(userDID, recs)
100 return recs, nil113 return recs, nil
101 }114 }
102 115
@@ -105,14 +118,59 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
105 // (embedding KNN against user's liked articles), and recency. Scores are118 // (embedding KNN against user's liked articles), and recency. Scores are
106 // min-max normalized.119 // min-max normalized.
107 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {120 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
121+ artKey := userDID + "|" + strings.Join(languages, ",")
122+ if entry, ok := e.articleCache.Get(artKey); ok {
123+ return entry, nil
124+ }
125+
108 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)126 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
109 if err != nil {127 if err != nil {
110 return nil, err128 return nil, err
111 }129 }
112 normalizeArticleScores(recs)130 normalizeArticleScores(recs)
131+ e.articleCache.Add(artKey, recs)
113 return recs, nil132 return recs, nil
114 }133 }
115 134
135+func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) {
136+ if offset == 0 {
137+ if entry, ok := e.globalTrendingCache.Get(userDID); ok {
138+ return entry, nil
139+ }
140+ }
141+
142+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
143+ items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)
144+ if err != nil {
145+ return nil, err
146+ }
147+
148+ if offset == 0 {
149+ e.globalTrendingCache.Add(userDID, items)
150+ }
151+ return items, nil
152+}
153+
154+func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) {
155+ key := userDID + "|" + strings.Join(languages, ",")
156+ if offset == 0 {
157+ if entry, ok := e.personalTrendingCache.Get(key); ok {
158+ return entry, nil
159+ }
160+ }
161+
162+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
163+ items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)
164+ if err != nil {
165+ return nil, err
166+ }
167+
168+ if offset == 0 {
169+ e.personalTrendingCache.Add(key, items)
170+ }
171+ return items, nil
172+}
173+
116 // SignalWeights holds per-signal multipliers used in the recommendation scoring174 // SignalWeights holds per-signal multipliers used in the recommendation scoring
117 // formula. Weights are auto-tuned per user via a bandit-style reward/penalty175 // formula. Weights are auto-tuned per user via a bandit-style reward/penalty
118 // system (see weights.go). Each field maps to a column in176 // system (see weights.go). Each field maps to a column in
modified internal/db/social.go +1 -1
@@ -398,7 +398,7 @@ func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID,
398398 WHERE l.created_at >= ?
399399 AND l.author_did IN (
400400 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
401- FROM user_similarity us
401+ FROM recs.user_similarity us
402402 WHERE us.user_a = ? OR us.user_b = ?
403403 UNION SELECT ?
404404 UNION SELECT f.target_did FROM follows f WHERE f.user_did = ?
@@ -398,7 +398,7 @@ func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID,
398 WHERE l.created_at >= ?398 WHERE l.created_at >= ?
399 AND l.author_did IN (399 AND l.author_did IN (
400 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END400 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
401- FROM user_similarity us401+ FROM recs.user_similarity us
402 WHERE us.user_a = ? OR us.user_b = ?402 WHERE us.user_a = ? OR us.user_b = ?
403 UNION SELECT ?403 UNION SELECT ?
404 UNION SELECT f.target_did FROM follows f WHERE f.user_did = ?404 UNION SELECT f.target_did FROM follows f WHERE f.user_did = ?
modified internal/server/articles_handler.go +14 -5
@@ -19,17 +19,26 @@ import (
1919
2020 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) {
2121 fill := "none"
22- colorCls := "text-spot-muted"
22+ likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15"
2323 if liked {
2424 fill = "currentColor"
25- colorCls = "text-spot-red"
25+ likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25"
2626 }
27+
2728 w.Header().Set("Content-Type", "text/html")
28- cls := "text-spot-text rounded-pill px-3 py-1 text-xs font-bold transition inline-flex items-center gap-1.5 hover:text-spot-green"
29+
2930 if bordered {
30- cls = "border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5"
31+ fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
32+ } else {
33+ fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
34+ }
35+}
36+
37+func likeTitle(liked bool) string {
38+ if liked {
39+ return "Unlike"
3140 }
32- _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=%t" hx-target="this" hx-swap="outerHTML" class="%s"><svg class="w-3.5 h-3.5 %s" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, bordered, cls, colorCls, fill, count)
41+ return "Like"
3342 }
3443
3544 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
@@ -19,17 +19,26 @@ import (
19 19
20 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) {20 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) {
21 fill := "none"21 fill := "none"
22- colorCls := "text-spot-muted"22+ likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15"
23 if liked {23 if liked {
24 fill = "currentColor"24 fill = "currentColor"
25- colorCls = "text-spot-red"25+ likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25"
26 }26 }
27+
27 w.Header().Set("Content-Type", "text/html")28 w.Header().Set("Content-Type", "text/html")
28- cls := "text-spot-text rounded-pill px-3 py-1 text-xs font-bold transition inline-flex items-center gap-1.5 hover:text-spot-green"29+
29 if bordered {30 if bordered {
30- cls = "border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5"31+ fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
32+ } else {
33+ fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
34+ }
35+}
36+
37+func likeTitle(liked bool) string {
38+ if liked {
39+ return "Unlike"
31 }40 }
32- _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=%t" hx-target="this" hx-swap="outerHTML" class="%s"><svg class="w-3.5 h-3.5 %s" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, bordered, cls, colorCls, fill, count)41+ return "Like"
33 }42 }
34 43
35 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {44 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
modified internal/server/dashboard_handler.go +26 -62
@@ -28,9 +28,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
2828 globalTrending []*db.TrendingItem
2929 )
3030
31- page := pageFromRequest(r, 25)
32- since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
33-
3431 g, gCtx := errgroup.WithContext(ctx)
3532
3633 g.Go(func() error {
@@ -53,95 +50,65 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
5350
5451 g.Go(func() error {
5552 var err error
56- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", page.Limit()+1, page.Offset())
53+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0)
5754 if err != nil {
5855 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
59- return nil
60- }
61- totalFetched := len(articles)
62- page = page.Paginate(totalFetched)
63- if page.HasNext {
64- articles = articles[:page.PageSize]
6556 }
6657 return nil
6758 })
6859
6960 g.Go(func() error {
70- var err error
71- userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID)
72- if err != nil {
73- s.logger.Warn("failed to get user languages", "error", err, "did", user.DID)
74- }
61+ userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)
7562 return nil
7663 })
7764
7865 g.Go(func() error {
7966 var err error
8067 peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5)
81- if err != nil {
82- s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID)
83- }
84- return nil
68+ return err
8569 })
8670
8771 g.Go(func() error {
8872 var err error
8973 feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5)
90- if err != nil {
91- s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID)
92- }
93- return nil
74+ return err
9475 })
9576
96- g.Go(func() error {
97- var err error
98- globalTrending, err = s.dbs.Articles.ListTrendingArticles(gCtx, user.DID, since, 10, 0)
99- if err != nil {
100- s.logger.Warn("failed to list global trending", "error", err, "did", user.DID)
101- }
102- return nil
103- })
77+ if subCount == 0 {
78+ g.Go(func() error {
79+ var err error
80+ globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0)
81+ return err
82+ })
83+ } else {
84+ g.Go(func() error {
85+ var err error
86+ personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0)
87+ return err
88+ })
89+ }
10490
10591 if err := g.Wait(); err != nil {
106- s.logger.Warn("dashboard phase 1 error", "error", err, "did", user.DID)
92+ s.logger.Warn("dashboard error", "error", err, "did", user.DID)
10793 }
10894
109- g2, gCtx2 := errgroup.WithContext(ctx)
110-
111- g2.Go(func() error {
95+ if subCount > 0 {
11296 var err error
113- articleRecs, err = s.engine.GetArticleRecommendations(gCtx2, user.DID, userLangs, 5)
97+ articleRecs, err = s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
11498 if err != nil {
11599 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
116100 }
117- return nil
118- })
119-
120- g2.Go(func() error {
121- var err error
122- personalTrending, err = s.dbs.Articles.ListTrendingArticlesForUser(gCtx2, user.DID, since, userLangs, 5, 0)
123- if err != nil {
124- s.logger.Warn("failed to list personal trending", "error", err, "did", user.DID)
125- }
126- return nil
127- })
128-
129- g2.Go(func() error {
130- resolvePeopleHandles(gCtx2, peopleRecs)
131- return nil
132- })
133-
134- if err := g2.Wait(); err != nil {
135- s.logger.Warn("dashboard phase 2 error", "error", err, "did", user.DID)
136101 }
137102
103+ resolvePeopleHandles(ctx, peopleRecs)
104+
138105 var impressions []cluster.Impression
139- for _, rec := range feedRecs {
140- impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})
141- }
142106 for _, rec := range articleRecs {
143107 impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL})
144108 }
109+ for _, rec := range feedRecs {
110+ impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})
111+ }
145112 if len(impressions) > 0 {
146113 if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil {
147114 s.logger.Warn("failed to record impressions", "error", err)
@@ -159,8 +126,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
159126
160127 s.render(w, r, "dashboard.html", map[string]any{
161128 "User": user,
162- "UnreadCount": unreadCount,
163129 "SubscriptionCount": subCount,
130+ "UnreadCount": unreadCount,
164131 "Articles": articles,
165132 "ArticleRecommendations": articleRecs,
166133 "FeedRecommendations": feedRecs,
@@ -168,9 +135,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
168135 "DiscoverPeople": discoverPeople,
169136 "PersonalTrending": personalTrending,
170137 "GlobalTrending": globalTrending,
171- "Page": page,
172- "BaseURL": "/dashboard",
173- "QueryParams": map[string]string{},
174138 "Now": time.Now(),
175139 })
176140 }
@@ -28,9 +28,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
28 globalTrending []*db.TrendingItem28 globalTrending []*db.TrendingItem
29 )29 )
30 30
31- page := pageFromRequest(r, 25)
32- since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
33-
34 g, gCtx := errgroup.WithContext(ctx)31 g, gCtx := errgroup.WithContext(ctx)
35 32
36 g.Go(func() error {33 g.Go(func() error {
@@ -53,95 +50,65 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
53 50
54 g.Go(func() error {51 g.Go(func() error {
55 var err error52 var err error
56- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", page.Limit()+1, page.Offset())53+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0)
57 if err != nil {54 if err != nil {
58 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)55 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
59- return nil
60- }
61- totalFetched := len(articles)
62- page = page.Paginate(totalFetched)
63- if page.HasNext {
64- articles = articles[:page.PageSize]
65 }56 }
66 return nil57 return nil
67 })58 })
68 59
69 g.Go(func() error {60 g.Go(func() error {
70- var err error61+ userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)
71- userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID)
72- if err != nil {
73- s.logger.Warn("failed to get user languages", "error", err, "did", user.DID)
74- }
75 return nil62 return nil
76 })63 })
77 64
78 g.Go(func() error {65 g.Go(func() error {
79 var err error66 var err error
80 peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5)67 peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5)
81- if err != nil {68+ return err
82- s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID)
83- }
84- return nil
85 })69 })
86 70
87 g.Go(func() error {71 g.Go(func() error {
88 var err error72 var err error
89 feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5)73 feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5)
90- if err != nil {74+ return err
91- s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID)
92- }
93- return nil
94 })75 })
95 76
96- g.Go(func() error {77+ if subCount == 0 {
97- var err error78+ g.Go(func() error {
98- globalTrending, err = s.dbs.Articles.ListTrendingArticles(gCtx, user.DID, since, 10, 0)79+ var err error
99- if err != nil {80+ globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0)
100- s.logger.Warn("failed to list global trending", "error", err, "did", user.DID)81+ return err
101- }82+ })
102- return nil83+ } else {
103- })84+ g.Go(func() error {
85+ var err error
86+ personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0)
87+ return err
88+ })
89+ }
104 90
105 if err := g.Wait(); err != nil {91 if err := g.Wait(); err != nil {
106- s.logger.Warn("dashboard phase 1 error", "error", err, "did", user.DID)92+ s.logger.Warn("dashboard error", "error", err, "did", user.DID)
107 }93 }
108 94
109- g2, gCtx2 := errgroup.WithContext(ctx)95+ if subCount > 0 {
110-
111- g2.Go(func() error {
112 var err error96 var err error
113- articleRecs, err = s.engine.GetArticleRecommendations(gCtx2, user.DID, userLangs, 5)97+ articleRecs, err = s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
114 if err != nil {98 if err != nil {
115 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)99 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
116 }100 }
117- return nil
118- })
119-
120- g2.Go(func() error {
121- var err error
122- personalTrending, err = s.dbs.Articles.ListTrendingArticlesForUser(gCtx2, user.DID, since, userLangs, 5, 0)
123- if err != nil {
124- s.logger.Warn("failed to list personal trending", "error", err, "did", user.DID)
125- }
126- return nil
127- })
128-
129- g2.Go(func() error {
130- resolvePeopleHandles(gCtx2, peopleRecs)
131- return nil
132- })
133-
134- if err := g2.Wait(); err != nil {
135- s.logger.Warn("dashboard phase 2 error", "error", err, "did", user.DID)
136 }101 }
137 102
103+ resolvePeopleHandles(ctx, peopleRecs)
104+
138 var impressions []cluster.Impression105 var impressions []cluster.Impression
139- for _, rec := range feedRecs {
140- impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})
141- }
142 for _, rec := range articleRecs {106 for _, rec := range articleRecs {
143 impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL})107 impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL})
144 }108 }
109+ for _, rec := range feedRecs {
110+ impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})
111+ }
145 if len(impressions) > 0 {112 if len(impressions) > 0 {
146 if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil {113 if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil {
147 s.logger.Warn("failed to record impressions", "error", err)114 s.logger.Warn("failed to record impressions", "error", err)
@@ -159,8 +126,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
159 126
160 s.render(w, r, "dashboard.html", map[string]any{127 s.render(w, r, "dashboard.html", map[string]any{
161 "User": user,128 "User": user,
162- "UnreadCount": unreadCount,
163 "SubscriptionCount": subCount,129 "SubscriptionCount": subCount,
130+ "UnreadCount": unreadCount,
164 "Articles": articles,131 "Articles": articles,
165 "ArticleRecommendations": articleRecs,132 "ArticleRecommendations": articleRecs,
166 "FeedRecommendations": feedRecs,133 "FeedRecommendations": feedRecs,
@@ -168,9 +135,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
168 "DiscoverPeople": discoverPeople,135 "DiscoverPeople": discoverPeople,
169 "PersonalTrending": personalTrending,136 "PersonalTrending": personalTrending,
170 "GlobalTrending": globalTrending,137 "GlobalTrending": globalTrending,
171- "Page": page,
172- "BaseURL": "/dashboard",
173- "QueryParams": map[string]string{},
174 "Now": time.Now(),138 "Now": time.Now(),
175 })139 })
176 }140 }
modified internal/server/feeds_handler.go +3 -3
@@ -350,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
350350 for _, fu := range feedURLs {
351351 f := &db.Feed{
352352 FeedURL: fu.URL,
353- Title: db.NullStr(fu.Title),
354- SiteURL: db.NullStr(fu.SiteURL),
355- Description: db.NullStr(fu.Description),
353+ Title: db.NullStr(fu.Title),
354+ SiteURL: db.NullStr(fu.SiteURL),
355+ Description: db.NullStr(fu.Description),
356356 }
357357 if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil {
358358 s.logger.Error("failed to upsert feed", "error", upsertErr)
@@ -350,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
350 for _, fu := range feedURLs {350 for _, fu := range feedURLs {
351 f := &db.Feed{351 f := &db.Feed{
352 FeedURL: fu.URL,352 FeedURL: fu.URL,
353- Title: db.NullStr(fu.Title),353+ Title: db.NullStr(fu.Title),
354- SiteURL: db.NullStr(fu.SiteURL),354+ SiteURL: db.NullStr(fu.SiteURL),
355- Description: db.NullStr(fu.Description),355+ Description: db.NullStr(fu.Description),
356 }356 }
357 if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil {357 if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil {
358 s.logger.Error("failed to upsert feed", "error", upsertErr)358 s.logger.Error("failed to upsert feed", "error", upsertErr)
modified internal/tmpl/dashboard.html +61 -141
@@ -12,9 +12,9 @@
1212 {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}}
1313 </p>
1414
15-{{if eq .SubscriptionCount 0}}
1615 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
1716
17+{{if eq .SubscriptionCount 0}}
1818 <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8">
1919 <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
2020 <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
@@ -26,184 +26,104 @@
2626 Add feeds
2727 </a>
2828 </div>
29-
30-{{if .GlobalTrending}}
29+{{else if .Articles}}
30+{{if .ArticleRecommendations}}
3131 <div class="mb-8">
32- <div class="flex items-center justify-between mb-4">
33- <h2 class="text-lg font-semibold text-spot-text">Trending</h2>
34- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
35- </div>
36- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
37- {{range .GlobalTrending}}
38- {{template "trending-card.html" .}}
32+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
33+ <div class="space-y-3">
34+ {{range .ArticleRecommendations}}
35+ {{template "recommendation-article-card.html" .}}
3936 {{end}}
4037 </div>
4138 </div>
4239 {{end}}
4340
44-{{if .FeedRecommendations}}
4541 <div class="mb-8">
46- <h2 class="text-lg font-semibold text-spot-text mb-4">Popular feeds to get started</h2>
47- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
48- {{range .FeedRecommendations}}
49- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
42+ <div class="flex items-center justify-between mb-4">
43+ <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
44+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a>
45+ </div>
46+ <div class="space-y-3">
47+ {{range .Articles}}
48+ {{template "article-card.html" .}}
5049 {{end}}
5150 </div>
51+ {{if gt .UnreadCount 5}}
52+ <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a>
53+ {{end}}
5254 </div>
53-{{end}}
54-
5555 {{else}}
56-<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
57-
58-{{if .Articles}}
59-<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
60- <div class="lg:col-span-2">
61- {{if .ArticleRecommendations}}
62- <div class="mb-8">
63- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
64- <div class="space-y-3">
65- {{range .ArticleRecommendations}}
66- {{template "recommendation-article-card.html" .}}
67- {{end}}
68- </div>
69- </div>
56+{{if .ArticleRecommendations}}
57+<div class="mb-8">
58+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
59+ <div class="space-y-3">
60+ {{range .ArticleRecommendations}}
61+ {{template "recommendation-article-card.html" .}}
7062 {{end}}
63+ </div>
64+</div>
65+{{end}}
7166
72- <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
73- <div id="article-list" class="space-y-3">
74- {{range .Articles}}
75- {{template "article-card.html" .}}
76- {{end}}
67+<div class="mb-8">
68+ <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
69+ <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
70+ <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
71+ <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
7772 </div>
78-
79- {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}
73+ <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3>
74+ <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
8075 </div>
76+</div>
77+{{end}}
8178
82- <div class="space-y-8">
83- {{if .PersonalTrending}}
84- <div>
85- <div class="flex items-center justify-between mb-4">
86- <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2>
87- <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
88- </div>
89- <div class="space-y-3">
90- {{range .PersonalTrending}}
91- {{template "trending-card.html" .}}
92- {{end}}
93- </div>
94- </div>
95- {{end}}
79+{{if or .GlobalTrending .PersonalTrending}}
80+<div class="mb-8">
81+ <div class="flex items-center justify-between mb-4">
82+ <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2>
83+ <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
84+ </div>
85+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
86+ {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}}
87+ {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}}
88+ </div>
89+</div>
90+{{end}}
9691
97- {{if or .FollowedPeople .DiscoverPeople}}
92+{{if or .FollowedPeople .DiscoverPeople}}
93+<div class="mb-8">
94+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
95+ {{if .FollowedPeople}}
9896 <div>
99- {{if .FollowedPeople}}
10097 <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
10198 <div class="space-y-3">
10299 {{range .FollowedPeople}}
103100 {{template "profile-card.html" .}}
104101 {{end}}
105102 </div>
106- {{end}}
107- {{if .DiscoverPeople}}
108- <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-8{{end}}">Discover new readers</h2>
109- <div class="space-y-3">
110- {{range .DiscoverPeople}}
111- {{template "profile-card.html" .}}
112- {{end}}
113- </div>
114- {{end}}
115103 </div>
116104 {{end}}
117-
118- {{if .FeedRecommendations}}
105+ {{if .DiscoverPeople}}
119106 <div>
120- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
107+ <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
121108 <div class="space-y-3">
122- {{range .FeedRecommendations}}
123- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
109+ {{range .DiscoverPeople}}
110+ {{template "profile-card.html" .}}
124111 {{end}}
125112 </div>
126113 </div>
127114 {{end}}
128115 </div>
129116 </div>
130-{{else}}
131-<div id="article-list"></div>
132-<div class="bg-spot-surface rounded-2xl py-14 px-6 text-center mb-8">
133- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
134- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
135- </div>
136- <h2 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h2>
137- <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
138-</div>
117+{{end}}
139118
140-{{if .ArticleRecommendations}}
119+{{if .FeedRecommendations}}
141120 <div class="mb-8">
142- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
143- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
144- {{range .ArticleRecommendations}}
145- {{template "recommendation-article-card.html" .}}
121+ <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2>
122+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
123+ {{range .FeedRecommendations}}
124+ {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
146125 {{end}}
147126 </div>
148127 </div>
149128 {{end}}
150-
151-{{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}}
152-<div class="space-y-8">
153- {{if .PersonalTrending}}
154- <div>
155- <div class="flex items-center justify-between mb-4">
156- <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2>
157- <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
158- </div>
159- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
160- {{range .PersonalTrending}}
161- {{template "trending-card.html" .}}
162- {{end}}
163- </div>
164- </div>
165- {{end}}
166-
167- {{if or .FollowedPeople .DiscoverPeople}}
168- <div>
169- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
170- {{if .FollowedPeople}}
171- <div>
172- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
173- <div class="space-y-3">
174- {{range .FollowedPeople}}
175- {{template "profile-card.html" .}}
176- {{end}}
177- </div>
178- </div>
179- {{end}}
180- {{if .DiscoverPeople}}
181- <div>
182- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
183- <div class="space-y-3">
184- {{range .DiscoverPeople}}
185- {{template "profile-card.html" .}}
186- {{end}}
187- </div>
188- </div>
189- {{end}}
190- </div>
191- </div>
192- {{end}}
193-
194- {{if .FeedRecommendations}}
195- <div>
196- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
197- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
198- {{range .FeedRecommendations}}
199- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
200- {{end}}
201- </div>
202- </div>
203- {{end}}
204-</div>
205-{{end}}
206-
207-{{end}}
208-{{end}}
209129 {{end}}
@@ -12,9 +12,9 @@
12 {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}}12 {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}}
13 </p>13 </p>
14 14
15-{{if eq .SubscriptionCount 0}}
16 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>15 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
17 16
17+{{if eq .SubscriptionCount 0}}
18 <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8">18 <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8">
19 <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">19 <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
20 <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>20 <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
@@ -26,184 +26,104 @@
26 Add feeds26 Add feeds
27 </a>27 </a>
28 </div>28 </div>
29-29+{{else if .Articles}}
30-{{if .GlobalTrending}}30+{{if .ArticleRecommendations}}
31 <div class="mb-8">31 <div class="mb-8">
32- <div class="flex items-center justify-between mb-4">32+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
33- <h2 class="text-lg font-semibold text-spot-text">Trending</h2>33+ <div class="space-y-3">
34- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>34+ {{range .ArticleRecommendations}}
35- </div>35+ {{template "recommendation-article-card.html" .}}
36- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
37- {{range .GlobalTrending}}
38- {{template "trending-card.html" .}}
39 {{end}}36 {{end}}
40 </div>37 </div>
41 </div>38 </div>
42 {{end}}39 {{end}}
43 40
44-{{if .FeedRecommendations}}
45 <div class="mb-8">41 <div class="mb-8">
46- <h2 class="text-lg font-semibold text-spot-text mb-4">Popular feeds to get started</h2>42+ <div class="flex items-center justify-between mb-4">
47- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">43+ <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
48- {{range .FeedRecommendations}}44+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a>
49- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}45+ </div>
46+ <div class="space-y-3">
47+ {{range .Articles}}
48+ {{template "article-card.html" .}}
50 {{end}}49 {{end}}
51 </div>50 </div>
51+ {{if gt .UnreadCount 5}}
52+ <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a>
53+ {{end}}
52 </div>54 </div>
53-{{end}}
54-
55 {{else}}55 {{else}}
56-<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>56+{{if .ArticleRecommendations}}
57-57+<div class="mb-8">
58-{{if .Articles}}58+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
59-<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">59+ <div class="space-y-3">
60- <div class="lg:col-span-2">60+ {{range .ArticleRecommendations}}
61- {{if .ArticleRecommendations}}61+ {{template "recommendation-article-card.html" .}}
62- <div class="mb-8">
63- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
64- <div class="space-y-3">
65- {{range .ArticleRecommendations}}
66- {{template "recommendation-article-card.html" .}}
67- {{end}}
68- </div>
69- </div>
70 {{end}}62 {{end}}
63+ </div>
64+</div>
65+{{end}}
71 66
72- <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>67+<div class="mb-8">
73- <div id="article-list" class="space-y-3">68+ <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
74- {{range .Articles}}69+ <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
75- {{template "article-card.html" .}}70+ <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
76- {{end}}71+ <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
77 </div>72 </div>
78-73+ <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3>
79- {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}74+ <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
80 </div>75 </div>
76+</div>
77+{{end}}
81 78
82- <div class="space-y-8">79+{{if or .GlobalTrending .PersonalTrending}}
83- {{if .PersonalTrending}}80+<div class="mb-8">
84- <div>81+ <div class="flex items-center justify-between mb-4">
85- <div class="flex items-center justify-between mb-4">82+ <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2>
86- <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2>83+ <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
87- <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>84+ </div>
88- </div>85+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
89- <div class="space-y-3">86+ {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}}
90- {{range .PersonalTrending}}87+ {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}}
91- {{template "trending-card.html" .}}88+ </div>
92- {{end}}89+</div>
93- </div>90+{{end}}
94- </div>
95- {{end}}
96 91
97- {{if or .FollowedPeople .DiscoverPeople}}92+{{if or .FollowedPeople .DiscoverPeople}}
93+<div class="mb-8">
94+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
95+ {{if .FollowedPeople}}
98 <div>96 <div>
99- {{if .FollowedPeople}}
100 <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>97 <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
101 <div class="space-y-3">98 <div class="space-y-3">
102 {{range .FollowedPeople}}99 {{range .FollowedPeople}}
103 {{template "profile-card.html" .}}100 {{template "profile-card.html" .}}
104 {{end}}101 {{end}}
105 </div>102 </div>
106- {{end}}
107- {{if .DiscoverPeople}}
108- <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-8{{end}}">Discover new readers</h2>
109- <div class="space-y-3">
110- {{range .DiscoverPeople}}
111- {{template "profile-card.html" .}}
112- {{end}}
113- </div>
114- {{end}}
115 </div>103 </div>
116 {{end}}104 {{end}}
117-105+ {{if .DiscoverPeople}}
118- {{if .FeedRecommendations}}
119 <div>106 <div>
120- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>107+ <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
121 <div class="space-y-3">108 <div class="space-y-3">
122- {{range .FeedRecommendations}}109+ {{range .DiscoverPeople}}
123- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}110+ {{template "profile-card.html" .}}
124 {{end}}111 {{end}}
125 </div>112 </div>
126 </div>113 </div>
127 {{end}}114 {{end}}
128 </div>115 </div>
129 </div>116 </div>
130-{{else}}117+{{end}}
131-<div id="article-list"></div>
132-<div class="bg-spot-surface rounded-2xl py-14 px-6 text-center mb-8">
133- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
134- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
135- </div>
136- <h2 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h2>
137- <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
138-</div>
139 118
140-{{if .ArticleRecommendations}}119+{{if .FeedRecommendations}}
141 <div class="mb-8">120 <div class="mb-8">
142- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>121+ <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2>
143- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">122+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
144- {{range .ArticleRecommendations}}123+ {{range .FeedRecommendations}}
145- {{template "recommendation-article-card.html" .}}124+ {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
146 {{end}}125 {{end}}
147 </div>126 </div>
148 </div>127 </div>
149 {{end}}128 {{end}}
150-
151-{{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}}
152-<div class="space-y-8">
153- {{if .PersonalTrending}}
154- <div>
155- <div class="flex items-center justify-between mb-4">
156- <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2>
157- <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
158- </div>
159- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
160- {{range .PersonalTrending}}
161- {{template "trending-card.html" .}}
162- {{end}}
163- </div>
164- </div>
165- {{end}}
166-
167- {{if or .FollowedPeople .DiscoverPeople}}
168- <div>
169- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
170- {{if .FollowedPeople}}
171- <div>
172- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
173- <div class="space-y-3">
174- {{range .FollowedPeople}}
175- {{template "profile-card.html" .}}
176- {{end}}
177- </div>
178- </div>
179- {{end}}
180- {{if .DiscoverPeople}}
181- <div>
182- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
183- <div class="space-y-3">
184- {{range .DiscoverPeople}}
185- {{template "profile-card.html" .}}
186- {{end}}
187- </div>
188- </div>
189- {{end}}
190- </div>
191- </div>
192- {{end}}
193-
194- {{if .FeedRecommendations}}
195- <div>
196- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
197- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
198- {{range .FeedRecommendations}}
199- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
200- {{end}}
201- </div>
202- </div>
203- {{end}}
204-</div>
205-{{end}}
206-
207-{{end}}
208-{{end}}
209 {{end}}129 {{end}}
modified main.go +1 -1
@@ -99,7 +99,7 @@ func main() {
9999 })
100100 }
101101
102- engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger)
102+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig())
103103
104104 fetcher := feed.NewFetcher(siteFetcher)
105105 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
@@ -99,7 +99,7 @@ func main() {
99 })99 })
100 }100 }
101 101
102- engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger)102+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig())
103 103
104 fetcher := feed.NewFetcher(siteFetcher)104 fetcher := feed.NewFetcher(siteFetcher)
105 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))105 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))