Skip to content

Commit 2c13736

Browse files
explodedclaude
andcommitted
Add 'favourite' rating tier for stronger recommendation signal
Favourite (star icon, amber) sits above liked and carries 2x weight in the recommendation engine. All positive-signal queries (GetLikedShows, GetBothLikedShows) now include favourites. Claude API prompts pass favourites separately for better taste profiling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 743b975 commit 2c13736

13 files changed

Lines changed: 119 additions & 25 deletions

File tree

internal/db/queries.sql

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ DELETE FROM ratings WHERE user_id = ? AND show_id = ?;
3232

3333
-- name: CountRatings :one
3434
SELECT
35+
SUM(CASE WHEN rating='favourite' THEN 1 ELSE 0 END) AS favourite,
3536
SUM(CASE WHEN rating='liked' THEN 1 ELSE 0 END) AS liked,
3637
SUM(CASE WHEN rating='disliked' THEN 1 ELSE 0 END) AS disliked,
3738
COUNT(*) AS total
@@ -67,9 +68,14 @@ LEFT JOIN ratings r ON r.show_id = s.id AND r.user_id = ?
6768
WHERE r.show_id IS NULL
6869
ORDER BY s.popularity DESC;
6970

71+
-- name: GetFavouriteShows :many
72+
SELECT s.* FROM shows s
73+
JOIN ratings r ON r.show_id = s.id AND r.user_id = ? AND r.rating = 'favourite'
74+
ORDER BY r.rated_at DESC;
75+
7076
-- name: GetLikedShows :many
7177
SELECT s.* FROM shows s
72-
JOIN ratings r ON r.show_id = s.id AND r.user_id = ? AND r.rating = 'liked'
78+
JOIN ratings r ON r.show_id = s.id AND r.user_id = ? AND r.rating IN ('liked', 'favourite')
7379
ORDER BY r.rated_at DESC;
7480

7581
-- name: GetDislikedShows :many
@@ -132,6 +138,6 @@ ON CONFLICT(partnership_id) DO UPDATE SET
132138

133139
-- name: GetBothLikedShows :many
134140
SELECT s.* FROM shows s
135-
JOIN ratings r1 ON r1.show_id = s.id AND r1.user_id = ? AND r1.rating = 'liked'
136-
JOIN ratings r2 ON r2.show_id = s.id AND r2.user_id = ? AND r2.rating = 'liked'
141+
JOIN ratings r1 ON r1.show_id = s.id AND r1.user_id = ? AND r1.rating IN ('liked', 'favourite')
142+
JOIN ratings r2 ON r2.show_id = s.id AND r2.user_id = ? AND r2.rating IN ('liked', 'favourite')
137143
ORDER BY s.popularity DESC;

internal/db/queries.sql.go

Lines changed: 53 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS shows (
2222
CREATE TABLE IF NOT EXISTS ratings (
2323
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
2424
show_id TEXT NOT NULL REFERENCES shows(id),
25-
rating TEXT NOT NULL CHECK (rating IN ('liked','disliked','unseen')),
25+
rating TEXT NOT NULL CHECK (rating IN ('favourite','liked','disliked','unseen')),
2626
rated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
2727
PRIMARY KEY (user_id, show_id)
2828
);

internal/handlers/catalog.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
type CatalogData struct {
1111
PageData
1212
Shows []ShowWithRating
13+
Favourite int64
1314
Liked int64
1415
Disliked int64
1516
Unrated int64
@@ -56,15 +57,17 @@ func (h *Handler) Catalog(w http.ResponseWriter, r *http.Request) {
5657
filtered = append(filtered, swr)
5758
}
5859

60+
favourite := int64(counts.Favourite.Float64)
5961
liked := int64(counts.Liked.Float64)
6062
disliked := int64(counts.Disliked.Float64)
6163

6264
data := CatalogData{
6365
PageData: h.basePageDataWithPartners(r, "catalog"),
6466
Shows: filtered,
67+
Favourite: favourite,
6568
Liked: liked,
6669
Disliked: disliked,
67-
Unrated: int64(len(shows)) - liked - disliked,
70+
Unrated: int64(len(shows)) - favourite - liked - disliked,
6871
Filter: filter,
6972
Search: r.URL.Query().Get("q"),
7073
ShowCount: len(shows),

internal/handlers/profile.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type IncomingRequest struct {
3131

3232
type ProfileData struct {
3333
PageData
34+
Favourite int64
3435
Liked int64
3536
Disliked int64
3637
Total int64
@@ -50,6 +51,7 @@ func (h *Handler) Profile(w http.ResponseWriter, r *http.Request) {
5051
userEmail := middleware.GetUserEmail(r.Context())
5152

5253
counts, _ := h.queries.CountRatings(r.Context(), userID)
54+
favourite := int64(counts.Favourite.Float64)
5355
liked := int64(counts.Liked.Float64)
5456
disliked := int64(counts.Disliked.Float64)
5557

@@ -65,6 +67,7 @@ func (h *Handler) Profile(w http.ResponseWriter, r *http.Request) {
6567

6668
data := ProfileData{
6769
PageData: h.basePageDataWithPartners(r, "profile"),
70+
Favourite: favourite,
6871
Liked: liked,
6972
Disliked: disliked,
7073
Total: counts.Total,

internal/handlers/ratings.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func (h *Handler) SetRating(w http.ResponseWriter, r *http.Request) {
1717
showID := r.PathValue("show_id")
1818
rating := r.FormValue("rating")
1919

20-
if rating != "liked" && rating != "disliked" && rating != "unseen" {
20+
if rating != "favourite" && rating != "liked" && rating != "disliked" && rating != "unseen" {
2121
http.Error(w, "invalid rating", http.StatusBadRequest)
2222
return
2323
}

internal/recommend/engine.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ func (e *Engine) ScoreShows(ctx context.Context, userID int64) ([]ScoredShow, er
6565
gv := genreVector(r.Genre.String)
6666
weight := 0.0
6767
switch r.Rating {
68+
case "favourite":
69+
weight = 2.0
6870
case "liked":
6971
weight = 1.0
7072
case "disliked":
@@ -207,6 +209,7 @@ func (e *Engine) generateVerdict(ctx context.Context, userID int64) (*Verdict, e
207209
return e.fallbackVerdict(ctx, userID)
208210
}
209211

212+
favourites, _ := e.queries.GetFavouriteShows(ctx, userID)
210213
liked, err := e.queries.GetLikedShows(ctx, userID)
211214
if err != nil || len(liked) == 0 {
212215
return e.fallbackVerdict(ctx, userID)
@@ -218,16 +221,17 @@ func (e *Engine) generateVerdict(ctx context.Context, userID int64) (*Verdict, e
218221
return e.fallbackVerdict(ctx, userID)
219222
}
220223

224+
favouriteTitles := titlesStr(favourites)
221225
likedTitles := titlesStr(liked)
222226
dislikedTitles := titlesStr(disliked)
223227
candidateTitles := titlesStr(top)
224228

225229
prompt := fmt.Sprintf(
226-
"You are an opinionated cinephile recommending TV series. The user liked: %s. They disliked: %s. "+
230+
"You are an opinionated cinephile recommending TV series. The user's absolute favourites: %s. They also liked: %s. They disliked: %s. "+
227231
"Recommend exactly ONE TV series from this candidate list: %s. "+
228232
"Respond with valid JSON only, no markdown: "+
229233
`{"pick":"<exact title>","headline":"You should watch <Title>.","verdict":"<2 sentences, max 50 words, in the voice of a knowledgeable film-friend explaining why>"}`,
230-
likedTitles, dislikedTitles, candidateTitles,
234+
favouriteTitles, likedTitles, dislikedTitles, candidateTitles,
231235
)
232236

233237
body := map[string]interface{}{
@@ -345,7 +349,9 @@ func (e *Engine) ScoreShowsTogether(ctx context.Context, userA, userB int64) ([]
345349
for _, r := range ratingsA {
346350
gv := genreVector(r.Genre.String)
347351
weight := 0.0
348-
if r.Rating == "liked" {
352+
if r.Rating == "favourite" {
353+
weight = 2.0
354+
} else if r.Rating == "liked" {
349355
weight = 1.0
350356
} else if r.Rating == "disliked" {
351357
weight = -1.0
@@ -358,7 +364,9 @@ func (e *Engine) ScoreShowsTogether(ctx context.Context, userA, userB int64) ([]
358364
for _, r := range ratingsB {
359365
gv := genreVector(r.Genre.String)
360366
weight := 0.0
361-
if r.Rating == "liked" {
367+
if r.Rating == "favourite" {
368+
weight = 2.0
369+
} else if r.Rating == "liked" {
362370
weight = 1.0
363371
} else if r.Rating == "disliked" {
364372
weight = -1.0
@@ -498,8 +506,10 @@ func (e *Engine) generateTogetherVerdict(ctx context.Context, partnershipID, use
498506
return e.fallbackTogetherVerdict(ctx, partnershipID, userA, userB)
499507
}
500508

509+
favouritesA, _ := e.queries.GetFavouriteShows(ctx, userA)
501510
likedA, _ := e.queries.GetLikedShows(ctx, userA)
502511
dislikedA, _ := e.queries.GetDislikedShows(ctx, userA)
512+
favouritesB, _ := e.queries.GetFavouriteShows(ctx, userB)
503513
likedB, _ := e.queries.GetLikedShows(ctx, userB)
504514
dislikedB, _ := e.queries.GetDislikedShows(ctx, userB)
505515

@@ -510,13 +520,13 @@ func (e *Engine) generateTogetherVerdict(ctx context.Context, partnershipID, use
510520

511521
prompt := fmt.Sprintf(
512522
"You are an opinionated cinephile recommending a TV series for two people to watch together. "+
513-
"Person A liked: %s. Person A disliked: %s. "+
514-
"Person B liked: %s. Person B disliked: %s. "+
523+
"Person A's favourites: %s. Person A also liked: %s. Person A disliked: %s. "+
524+
"Person B's favourites: %s. Person B also liked: %s. Person B disliked: %s. "+
515525
"Recommend exactly ONE series from this candidate list that they would BOTH enjoy: %s. "+
516526
"Respond with valid JSON only, no markdown: "+
517527
`{"pick":"<exact title>","headline":"Tonight, watch <Title> together.","verdict":"<2 sentences, max 50 words, explaining why this works for both>"}`,
518-
titlesStr(likedA), titlesStr(dislikedA),
519-
titlesStr(likedB), titlesStr(dislikedB),
528+
titlesStr(favouritesA), titlesStr(likedA), titlesStr(dislikedA),
529+
titlesStr(favouritesB), titlesStr(likedB), titlesStr(dislikedB),
520530
titlesStr(top),
521531
)
522532

static/styles.css

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
--accent-deep: #b8772e;
2121
--accent-ink: #1a1108;
2222

23+
--favourite: #e7a857;
2324
--like: #6fb98f;
2425
--dislike: #d76b6b;
2526
--unseen: #6c727f;
@@ -454,6 +455,7 @@ em { font-style: italic; }
454455
border-radius: 999px;
455456
display: inline-block;
456457
}
458+
.legend__chip--favourite .dot { background: var(--favourite); }
457459
.legend__chip--liked .dot { background: var(--like); }
458460
.legend__chip--disliked .dot { background: var(--dislike); }
459461
.legend__chip--unseen .dot { background: var(--bg-3); border: 1px solid var(--ink-3); }
@@ -550,7 +552,7 @@ em { font-style: italic; }
550552
position: absolute;
551553
inset: auto 8px 8px 8px;
552554
display: grid;
553-
grid-template-columns: 1fr 1fr 1fr;
555+
grid-template-columns: 1fr 1fr 1fr 1fr;
554556
gap: 4px;
555557
padding: 4px;
556558
background: rgba(11, 9, 8, 0.82);
@@ -578,6 +580,7 @@ em { font-style: italic; }
578580
}
579581
.rate-btn:hover { background: var(--bg-2); color: var(--ink-0); }
580582
.rate-btn svg { width: 14px; height: 14px; }
583+
.rate-btn--favourite.is-active { background: var(--favourite); color: #1a1108; }
581584
.rate-btn--liked.is-active { background: var(--like); color: #0a1a10; }
582585
.rate-btn--disliked.is-active { background: var(--dislike); color: #1a0a0a; }
583586
.rate-btn--unseen.is-active { background: var(--bg-3); color: var(--ink-0); }
@@ -598,9 +601,11 @@ em { font-style: italic; }
598601
z-index: 2;
599602
box-shadow: 0 2px 6px rgba(0,0,0,.4);
600603
}
604+
.poster-card[data-rating="favourite"] .poster-card__badge { display: flex; background: var(--favourite); color: #1a1108; }
601605
.poster-card[data-rating="liked"] .poster-card__badge { display: flex; background: var(--like); color: #0a1a10; }
602606
.poster-card[data-rating="disliked"] .poster-card__badge { display: flex; background: var(--dislike); color: #1a0a0a; }
603607
.poster-card[data-rating="unseen"] .poster-card__badge { display: flex; background: var(--bg-3); color: var(--ink-1); border: 1px solid var(--ink-3); }
608+
.poster-card[data-rating="favourite"] .poster-card__poster { box-shadow: 0 1px 2px rgba(0,0,0,.5), 0 12px 28px rgba(0,0,0,.55), inset 0 0 0 2px var(--favourite); }
604609
.poster-card[data-rating="liked"] .poster-card__poster { box-shadow: 0 1px 2px rgba(0,0,0,.5), 0 12px 28px rgba(0,0,0,.55), inset 0 0 0 2px var(--like); }
605610
.poster-card[data-rating="disliked"] .poster-card__poster { box-shadow: 0 1px 2px rgba(0,0,0,.5), 0 12px 28px rgba(0,0,0,.55), inset 0 0 0 2px var(--dislike); }
606611
.poster-card[data-rating="disliked"] .poster-card__poster img { opacity: 0.4; filter: grayscale(0.6); }
@@ -727,6 +732,8 @@ em { font-style: italic; }
727732
max-width: 60ch;
728733
}
729734
.rec-feature__actions { display: flex; gap: 10px; flex-wrap: wrap; }
735+
.rec-feature__fav-hint { font-size: 13px; color: var(--ink-2); margin: 10px 0 0; }
736+
.btn-link { color: var(--favourite); text-decoration: underline; text-underline-offset: 2px; }
730737

731738
/* Poster row */
732739
.recs__row { margin-bottom: 56px; }
@@ -997,7 +1004,7 @@ em { font-style: italic; }
9971004

9981005
.stat-grid {
9991006
display: grid;
1000-
grid-template-columns: repeat(4, 1fr);
1007+
grid-template-columns: repeat(5, 1fr);
10011008
gap: 24px;
10021009
}
10031010
.stat-grid > div { display: flex; flex-direction: column; gap: 4px; }

templates/catalog.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ <h2 class="h-display h-display--sm">The catalog</h2>
99
<p class="lede lede--tight">Rate as much or as little as you like. Filters narrow the wall.</p>
1010
</div>
1111
<div class="catalog__stats">
12+
<div class="stat"><span class="stat__num">{{.Favourite}}</span><span class="stat__lbl">favourite</span></div>
1213
<div class="stat"><span class="stat__num">{{.Liked}}</span><span class="stat__lbl">liked</span></div>
1314
<div class="stat"><span class="stat__num">{{.Disliked}}</span><span class="stat__lbl">disliked</span></div>
1415
<div class="stat"><span class="stat__num">{{.Unrated}}</span><span class="stat__lbl">unrated</span></div>
@@ -29,6 +30,9 @@ <h2 class="h-display h-display--sm">The catalog</h2>
2930
<button class="filter{{if or (eq .Filter "") (eq .Filter "all")}} filter--active{{end}}"
3031
hx-get="/catalog/filter?filter=all"
3132
hx-target="#catalogGrid">All</button>
33+
<button class="filter{{if eq .Filter "favourite"}} filter--active{{end}}"
34+
hx-get="/catalog/filter?filter=favourite"
35+
hx-target="#catalogGrid">Favourite</button>
3236
<button class="filter{{if eq .Filter "liked"}} filter--active{{end}}"
3337
hx-get="/catalog/filter?filter=liked"
3438
hx-target="#catalogGrid">Liked</button>

templates/onboarding.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ <h2 class="h-display">Rate a few you know.</h2>
2323
all we need to start.
2424
</p>
2525
<div class="legend">
26+
<span class="legend__chip legend__chip--favourite"><span class="dot"></span>Favourite</span>
2627
<span class="legend__chip legend__chip--liked"><span class="dot"></span>Liked</span>
2728
<span class="legend__chip legend__chip--disliked"><span class="dot"></span>Disliked</span>
2829
<span class="legend__chip legend__chip--unseen"><span class="dot"></span>Haven't seen</span>

0 commit comments

Comments
 (0)