From 1ba76af353ab968d2e2ebaf4d43c553df941e78f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:50:10 -0400 Subject: [PATCH] feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/catalog/calendar_personal.go | 87 ++++++++++++++++++++++ internal/catalog/calendar_personal_test.go | 44 +++++++++++ 2 files changed, 131 insertions(+) create mode 100644 internal/catalog/calendar_personal.go create mode 100644 internal/catalog/calendar_personal_test.go diff --git a/internal/catalog/calendar_personal.go b/internal/catalog/calendar_personal.go new file mode 100644 index 00000000..02d31642 --- /dev/null +++ b/internal/catalog/calendar_personal.go @@ -0,0 +1,87 @@ +package catalog + +import ( + "context" + "fmt" +) + +// Per-profile id-set queries backing the calendar presets. All run on the same +// pool as the base calendar query. The watched-series rollup intentionally +// mirrors recommendations.GetPopularItems (COALESCE(e.series_id, wp.media_item_id)) +// so "engaged with a series" means the same thing everywhere; it is a one-line +// SQL expression, not worth a cross-package extraction. +const ( + followedItemIDsQuery = ` +SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2 +UNION +SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2 +UNION +SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id) +FROM user_watch_progress wp +LEFT JOIN episodes e ON e.content_id = wp.media_item_id +WHERE wp.user_id = $1 AND wp.profile_id = $2` + + favoriteItemIDsQuery = `SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2` + watchlistItemIDsQuery = `SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2` + + watchedItemIDsQuery = ` +SELECT media_item_id +FROM user_watch_progress +WHERE user_id = $1 AND profile_id = $2 AND completed = true AND media_item_id = ANY($3)` +) + +// ListFollowedItemIDs returns the profile's followed set: favorited ∪ watchlisted ∪ +// any series/movie they have watch progress on. +func (r *CalendarRepository) ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, followedItemIDsQuery, userID, profileID) +} + +// ListFavoriteItemIDs returns the profile's favorited content ids. +func (r *CalendarRepository) ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, favoriteItemIDsQuery, userID, profileID) +} + +// ListWatchlistItemIDs returns the profile's watchlisted content ids. +func (r *CalendarRepository) ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, watchlistItemIDsQuery, userID, profileID) +} + +func (r *CalendarRepository) queryIDs(ctx context.Context, query string, userID int, profileID string) ([]string, error) { + rows, err := r.pool.Query(ctx, query, userID, profileID) + if err != nil { + return nil, fmt.Errorf("calendar personal query: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning personal id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// ListWatchedItemIDs returns the subset of contentIDs the profile has completed, +// as a set. An empty input returns an empty set without querying. +func (r *CalendarRepository) ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error) { + watched := make(map[string]bool, len(contentIDs)) + if len(contentIDs) == 0 { + return watched, nil + } + rows, err := r.pool.Query(ctx, watchedItemIDsQuery, userID, profileID, contentIDs) + if err != nil { + return nil, fmt.Errorf("calendar watched query: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning watched id: %w", err) + } + watched[id] = true + } + return watched, rows.Err() +} diff --git a/internal/catalog/calendar_personal_test.go b/internal/catalog/calendar_personal_test.go new file mode 100644 index 00000000..cb1bed6d --- /dev/null +++ b/internal/catalog/calendar_personal_test.go @@ -0,0 +1,44 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestFollowedItemIDsQuery_UnionsAllSignals(t *testing.T) { + for _, fragment := range []string{ + "FROM user_favorites", + "FROM user_watchlist", + "user_watch_progress wp", + "LEFT JOIN episodes e ON e.content_id = wp.media_item_id", + "COALESCE(e.series_id, wp.media_item_id)", + "UNION", + } { + if !strings.Contains(followedItemIDsQuery, fragment) { + t.Fatalf("followedItemIDsQuery missing %q:\n%s", fragment, followedItemIDsQuery) + } + } +} + +func TestWatchedItemIDsQuery_FiltersCompletedWithinSet(t *testing.T) { + for _, fragment := range []string{ + "FROM user_watch_progress", + "completed = true", + "media_item_id = ANY($3)", + } { + if !strings.Contains(watchedItemIDsQuery, fragment) { + t.Fatalf("watchedItemIDsQuery missing %q:\n%s", fragment, watchedItemIDsQuery) + } + } +} + +func TestFavoriteAndWatchlistQueries_ScopeToProfile(t *testing.T) { + if !strings.Contains(favoriteItemIDsQuery, "FROM user_favorites") || + !strings.Contains(favoriteItemIDsQuery, "profile_id = $2") { + t.Fatalf("favoriteItemIDsQuery wrong:\n%s", favoriteItemIDsQuery) + } + if !strings.Contains(watchlistItemIDsQuery, "FROM user_watchlist") || + !strings.Contains(watchlistItemIDsQuery, "profile_id = $2") { + t.Fatalf("watchlistItemIDsQuery wrong:\n%s", watchlistItemIDsQuery) + } +}