Merge pull request #25 from Silo-Server/feat/calendar-presets
feat(calendar): personalized presets (Following default) with watched overlay
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
# Calendar Presets & Personalized Default — Design
|
||||
|
||||
- **Date:** 2026-05-29
|
||||
- **Status:** Approved (design); ready for implementation planning
|
||||
- **Scope:** `silo-server` (Go backend + web admin UI). Client follow-ups in `silo-android` / `silo-apple` are additive and optional.
|
||||
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
The calendar works but is not practical. It defaults to *every* upcoming airing in the catalog, so it is noisy and undifferentiated — the user has to mentally filter out shows they don't care about. We want a Simkl-style preset switcher with a personalized default, powered by the server's real watch data rather than a manually curated list.
|
||||
|
||||
## Goals
|
||||
|
||||
- Default the calendar to a personalized **Following** view (shows the profile actually engages with).
|
||||
- Offer a small set of presets, including a **server-wide** popularity view and an **external trending** view.
|
||||
- Keep it cheap under load (hundreds of concurrent users): presets must **filter, not compute**.
|
||||
- Make "what do I still need to watch this week" visible via a watched overlay.
|
||||
- Preserve backward compatibility for existing clients.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No cross-user caching of the windowed airing query yet (phased — add only if metrics show strain).
|
||||
- No pulling in external content that isn't in the library (e.g. a followed show whose next season hasn't been added). The calendar stays catalog-scoped.
|
||||
- No change to the week-view layout, timezone handling, or event ordering (recently tuned; left intact).
|
||||
- No region/country or "For Kids" presets (considered and cut).
|
||||
|
||||
## Presets
|
||||
|
||||
The selector replaces today's `All / Favorites / Watchlist` toggles.
|
||||
|
||||
| Preset | Shows | Source |
|
||||
|---|---|---|
|
||||
| **Following** *(default)* | Upcoming airings for series the profile has watched, favorited, or watchlisted | per-profile: `user_favorites` ∪ `user_watchlist` ∪ watched-series rollup |
|
||||
| **Popular here** | Airings for what is most-watched server-wide | cached `recommendation_cache` (`RecTypePopular`, global key) |
|
||||
| **Trending** | Airings for externally-trending shows that exist in the library | `trending_discover_snapshots` |
|
||||
| **Everything** | All catalog airings (today's behavior) | no restriction |
|
||||
|
||||
Per-profile vs server-wide is split cleanly across two presets: **Following** is personal (this profile), **Popular here** is the server-wide aggregate.
|
||||
|
||||
## Core principle: filter, don't compute
|
||||
|
||||
Every preset resolves to **the windowed airing candidate list ∩ a small id-set**. No preset triggers a fresh aggregation at request time.
|
||||
|
||||
1. **Step 1 — windowed candidate fetch** (unchanged from today): the 3-branch UNION in `internal/catalog/calendar_repo.go` over movies/episodes/season-premieres, scoped only by library access + content rating, across the ±2-day-padded window. Its result depends only on the access signature, not on viewer identity, so it remains cacheable across users later.
|
||||
2. **Step 2 — intersect with one id-set**, chosen by preset:
|
||||
- **Following** → resolve the profile's followed id-set once per request, then filter `itemIDExpr = ANY($set)`.
|
||||
- **Popular / Trending** → read the already-cached global id-set and intersect.
|
||||
- **Everything** → no restriction.
|
||||
3. **Step 3 — group by day, sort by viewer-local wall-clock time, respond** (unchanged).
|
||||
|
||||
The only heavy SQL is Step 1, which already runs today for every calendar load. Presets add a bounded id-set intersection, never a new aggregation.
|
||||
|
||||
### Unify the filter mechanism
|
||||
|
||||
All personal/preset filters resolve to "an id-set, then `itemIDExpr = ANY($set)`". This replaces the special-cased favorites/watchlist `EXISTS` subqueries in `appendPersonalFilterClause` with a single code path (favorites and watchlist become id-sets resolved from their tables — they are subsets of Following). One mechanism is easier to reason about and removes duplicated clause-building.
|
||||
|
||||
`itemIDExpr` continues to be `mi.content_id` for movies and `e.series_id` / `s.series_id` for episodes / season premieres, so a watched/favorited **movie** matches by its own content_id while a followed **series** matches by series id.
|
||||
|
||||
### Following id-set
|
||||
|
||||
Resolved per request as the DISTINCT union of:
|
||||
|
||||
- `SELECT media_item_id FROM user_favorites WHERE user_id = $u AND profile_id = $p`
|
||||
- `SELECT media_item_id FROM user_watchlist WHERE user_id = $u AND profile_id = $p`
|
||||
- `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 = $u AND wp.profile_id = $p`
|
||||
|
||||
"Engaged" = the presence of a `user_watch_progress` row (you've started it); no completion threshold required for Following. The rollup mirrors the established pattern in `GetPopularItems` (`internal/recommendations/repo.go`) — reuse that rollup expression rather than duplicate it.
|
||||
|
||||
The candidate list (Step 1) is small (one week of airings), so intersecting against a bounded array is cheap. Caveat: a pathologically large followed-set (a profile that has watched thousands of distinct series) makes the array large; if that ever shows up as hot, switch the Following preset to correlated `EXISTS` against the user's tables. Not building that now.
|
||||
|
||||
### Popular / Trending id-sets
|
||||
|
||||
- **Popular**: `GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")` → `[]ScoredItem`; use `MediaItemID` values (already series-level via the `GetPopularItems` rollup). Refreshed by the recommendations worker on a TTL; readers never aggregate live.
|
||||
- **Trending**: `TrendingSnapshotRepository.Get(ctx, source, window)` → `TrendingSnapshot.ContentIDs` (pre-resolved to catalog content_ids, viewer-agnostic). The calendar reads the canonical default snapshot (`tmdb` / `week`). Unioning multiple sources/windows is possible but out of scope.
|
||||
|
||||
**Access is preserved automatically:** Popular/Trending id-sets are viewer-agnostic and may include items a given profile cannot access. Because we intersect them with the access-scoped Step-1 candidates (library scoping + `MaxContentRating` already applied there), inaccessible items drop out naturally. No extra access check needed on the id-sets.
|
||||
|
||||
**Cold/empty cache:** if the Popular cache or Trending snapshot is empty (fresh server, worker hasn't run, provider unconfigured), the id-set is empty and the preset returns no rows. This must render the empty state, not an error.
|
||||
|
||||
## Watched overlay
|
||||
|
||||
After Step 1, decorate the windowed events with per-profile watched status:
|
||||
|
||||
- One bounded lookup: `SELECT media_item_id FROM user_watch_progress WHERE user_id = $u AND profile_id = $p AND completed = true AND media_item_id = ANY($episodeContentIds)` over the window's episode/movie content_ids.
|
||||
- Watched items render dimmed + ✓. This is a per-profile overlay applied after Step 1 (like the Following filter), so it does not affect the shareability of the Step-1 candidate set.
|
||||
- Premiere / finale / new-season badges extend the existing `buildBadges` logic in `internal/api/handlers/calendar.go`.
|
||||
|
||||
## UI / UX
|
||||
|
||||
- **Selector** (`web/src/pages/Calendar.tsx`): segmented pills on desktop, collapsing to a dropdown on narrow viewports — the same responsive swap the week navigator already uses. The library picker stays beside it. Both presentations bind to one selected-preset state.
|
||||
- **Empty state**: when **Following** has no upcoming airings (new or sparse profile), show a nudge with quick switches to Popular / Trending / Everything instead of a blank week.
|
||||
- **Persistence**: remember the profile's last-chosen preset in `localStorage` (keyed by profile id) so the calendar reopens where the user left it. Server-side profile preference is a possible future enhancement.
|
||||
- **Card cues** (`web/src/components/calendar/CalendarEventCard.tsx`, `web/src/lib/upcomingEventPresentation.ts`): badges + watched overlay as above.
|
||||
|
||||
## API contract
|
||||
|
||||
- Extend `/calendar`'s `filter` parameter to accept `following | popular | trending | everything`.
|
||||
- **Keep `all | favorites | watchlist` valid** so existing `silo-android` / `silo-apple` clients keep working unchanged. `all` and `everything` are equivalent. This is purely additive.
|
||||
- Add an additive `watched: bool` field (and any new badge string) to each event item. Older clients ignore unknown fields.
|
||||
- Default when `filter` is omitted: today it is `all`. The **web client** will request `following` explicitly; the server default stays `all` to avoid changing behavior for existing API consumers. (Default-view personalization is a client choice, not a server-contract change.)
|
||||
- **Profile requirement:** the `/calendar` route already runs behind `RequireProfile`, so a profile is always present. `following` (and legacy `favorites` / `watchlist`) plus the watched overlay are per-profile and resolve against it; `popular` / `trending` / `everything` / `all` are profile-agnostic and ignore it.
|
||||
|
||||
### Multi-repo coordination (per CLAUDE.md)
|
||||
|
||||
- `silo-android` / `silo-apple`: may adopt the new presets and the watched cue as independent follow-ups. Nothing here forces a lockstep change because old filter values and response shape remain valid.
|
||||
|
||||
## Implementation surface
|
||||
|
||||
Backend:
|
||||
- `internal/catalog/calendar_repo.go` — add an id-set restriction to `CalendarFilter`; generalize `appendPersonalFilterClause` into a single `ANY($set)` path; keep library/rating clauses intact.
|
||||
- A followed-id-set resolver, `ListFollowedSeriesIDs(ctx, userID, profileID)`, placed in the package that owns the favorites/watchlist/episode join; reuse the `GetPopularItems` rollup expression.
|
||||
- `internal/api/handlers/calendar.go` — validate the expanded `filter` set; orchestrate preset → id-set resolution (Following resolver, Popular cache read, Trending snapshot read); apply the watched overlay; extend `buildBadges`.
|
||||
|
||||
Frontend:
|
||||
- `web/src/pages/Calendar.tsx` — responsive selector + preset persistence + empty state.
|
||||
- `web/src/hooks/queries/calendar.ts` — new `filter` values; `watched` field on the event type.
|
||||
- `web/src/components/calendar/CalendarEventCard.tsx` — watched overlay + badge rendering.
|
||||
- `web/src/lib/upcomingEventPresentation.ts` — badge/treatment helpers.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Cold/empty Popular cache or Trending snapshot → empty preset → empty state, never an error.
|
||||
- Profile with no follows → Following empty → empty state with preset nudges.
|
||||
- Large followed-set → bounded array today; correlated-`EXISTS` fallback noted for later.
|
||||
- Movie vs episode/season id matching (`content_id` vs `series_id`) handled by the existing `itemIDExpr` selection per branch.
|
||||
- Specials (`season_number = 0`) remain excluded.
|
||||
- Access control (`MaxContentRating`, library scoping, disabled libraries) still enforced for every preset via Step 1; viewer-agnostic id-sets cannot leak inaccessible items because of the intersection.
|
||||
- Backward-compat filter values (`all | favorites | watchlist`) still resolve through the unified id-set path with identical results.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Repo** (`internal/catalog/calendar_repo_test.go`): each preset's id-set filter (following / popular / trending / everything), empty id-set → zero rows, access + rating enforcement preserved across presets, movie-by-content_id vs series-by-series_id matching, legacy favorites/watchlist parity.
|
||||
- **Handler** (`internal/api/handlers/calendar_test.go`): filter validation accepts new + legacy values; Popular/Trending cold cache degrades to empty gracefully; `watched` field populated correctly; watched lookup bounded to the window.
|
||||
- **Frontend**: selector responsive swap (pills ↔ dropdown), preset persistence across reloads, watched overlay + badge rendering, Following empty state.
|
||||
|
||||
## Future work
|
||||
|
||||
- Cross-user cache of the Step-1 windowed candidate set keyed by (window, access-signature), if load metrics justify it.
|
||||
- External enrichment of followed shows whose next season is not yet in the library.
|
||||
- Server-side persisted preset preference per profile.
|
||||
- Optional union of multiple Trending sources/windows.
|
||||
@@ -2,27 +2,69 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
)
|
||||
|
||||
type calendarRepository interface {
|
||||
ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error)
|
||||
}
|
||||
|
||||
// calendarPersonalRepo resolves per-profile id-sets and watched status.
|
||||
type calendarPersonalRepo interface {
|
||||
ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error)
|
||||
}
|
||||
|
||||
// calendarPopularSource reads the cached server-wide popular id-set.
|
||||
type calendarPopularSource interface {
|
||||
GetRecommendationCache(ctx context.Context, userID int, profileID, recType, sourceItemID string) ([]recommendations.ScoredItem, error)
|
||||
}
|
||||
|
||||
// calendarTrendingSource reads the external-trending snapshot.
|
||||
type calendarTrendingSource interface {
|
||||
Get(ctx context.Context, source, window string) (sections.TrendingSnapshot, bool, error)
|
||||
}
|
||||
|
||||
const (
|
||||
calendarFilterAll = "all"
|
||||
calendarFilterEverything = "everything"
|
||||
calendarFilterFollowing = "following"
|
||||
calendarFilterFavorites = "favorites"
|
||||
calendarFilterWatchlist = "watchlist"
|
||||
calendarFilterPopular = "popular"
|
||||
calendarFilterTrending = "trending"
|
||||
)
|
||||
|
||||
// The Trending preset reads this canonical external-trending snapshot
|
||||
// (see internal/sections trending snapshots).
|
||||
const (
|
||||
calendarTrendingSnapshotSource = "tmdb"
|
||||
calendarTrendingSnapshotWindow = "week"
|
||||
)
|
||||
|
||||
// CalendarHandler handles the calendar endpoint.
|
||||
type CalendarHandler struct {
|
||||
repo calendarRepository
|
||||
detailSvc *catalog.DetailService
|
||||
personal calendarPersonalRepo // nil-tolerant (per-profile presets degrade to empty)
|
||||
popular calendarPopularSource // nil when recommendations disabled
|
||||
trending calendarTrendingSource // nil when trending disabled
|
||||
}
|
||||
|
||||
// NewCalendarHandler creates a new CalendarHandler.
|
||||
func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler {
|
||||
return &CalendarHandler{repo: repo, detailSvc: detailSvc}
|
||||
// NewCalendarHandler creates a new CalendarHandler. The repo doubles as the
|
||||
// per-profile resolver since *catalog.CalendarRepository implements both.
|
||||
func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService, popular calendarPopularSource, trending calendarTrendingSource) *CalendarHandler {
|
||||
return &CalendarHandler{repo: repo, detailSvc: detailSvc, personal: repo, popular: popular, trending: trending}
|
||||
}
|
||||
|
||||
// --- Response types ---
|
||||
@@ -42,6 +84,7 @@ type calendarEventResponse struct {
|
||||
LocalAirDate string `json:"local_air_date"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
||||
Watched bool `json:"watched"`
|
||||
Badges []string `json:"badges"`
|
||||
}
|
||||
|
||||
@@ -79,33 +122,39 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
filter := q.Get("filter")
|
||||
if filter == "" {
|
||||
filter = "all"
|
||||
filter = calendarFilterAll
|
||||
}
|
||||
switch filter {
|
||||
case "all", "favorites", "watchlist":
|
||||
case calendarFilterAll, calendarFilterEverything, calendarFilterFollowing,
|
||||
calendarFilterFavorites, calendarFilterWatchlist, calendarFilterPopular, calendarFilterTrending:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid filter")
|
||||
return
|
||||
}
|
||||
|
||||
af := requestAccessFilter(r)
|
||||
|
||||
if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter")
|
||||
viewerLocation := catalog.CalendarLocation(q.Get("timezone"))
|
||||
|
||||
restrict, ids, err := h.resolveCalendarRestriction(r.Context(), filter, af)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to resolve calendar filter")
|
||||
return
|
||||
}
|
||||
// A restricting preset with no ids can never match — skip the windowed query.
|
||||
if restrict && len(ids) == 0 {
|
||||
writeJSON(w, http.StatusOK, calendarResponse{Events: []calendarDayResponse{}})
|
||||
return
|
||||
}
|
||||
|
||||
viewerLocation := catalog.CalendarLocation(q.Get("timezone"))
|
||||
|
||||
cf := catalog.CalendarFilter{
|
||||
Start: start.AddDate(0, 0, -2),
|
||||
End: end.AddDate(0, 0, 2),
|
||||
Filter: filter,
|
||||
AllowedLibraryIDs: af.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: af.DisabledLibraryIDs,
|
||||
MaxContentRating: af.MaxContentRating,
|
||||
UserID: af.UserID,
|
||||
ProfileID: af.ProfileID,
|
||||
RestrictByIDs: restrict,
|
||||
RestrictToIDs: ids,
|
||||
}
|
||||
|
||||
if v := q.Get("library_id"); v != "" {
|
||||
@@ -123,12 +172,82 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
watched := h.resolveWatched(r.Context(), af, events)
|
||||
|
||||
// Group events by date and build response.
|
||||
days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation)
|
||||
days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation, watched)
|
||||
writeJSON(w, http.StatusOK, calendarResponse{Events: days})
|
||||
}
|
||||
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location) []calendarDayResponse {
|
||||
// resolveCalendarRestriction maps a preset to an id-set restriction. restrict=false
|
||||
// means no restriction (everything/all). A nil/missing source degrades that preset
|
||||
// to an empty id-set (which the caller renders as an empty calendar).
|
||||
func (h *CalendarHandler) resolveCalendarRestriction(ctx context.Context, filter string, af catalog.AccessFilter) (restrict bool, ids []string, err error) {
|
||||
switch filter {
|
||||
case calendarFilterAll, calendarFilterEverything:
|
||||
return false, nil, nil
|
||||
case calendarFilterFollowing, calendarFilterFavorites, calendarFilterWatchlist:
|
||||
if h.personal == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
switch filter {
|
||||
case calendarFilterFavorites:
|
||||
ids, err = h.personal.ListFavoriteItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
case calendarFilterWatchlist:
|
||||
ids, err = h.personal.ListWatchlistItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
default: // following
|
||||
ids, err = h.personal.ListFollowedItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
}
|
||||
return true, ids, err
|
||||
case calendarFilterPopular:
|
||||
if h.popular == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
items, err := h.popular.GetRecommendationCache(ctx, recommendations.GlobalCacheUserID, recommendations.GlobalCacheProfileID, recommendations.RecTypePopular, "")
|
||||
if err != nil {
|
||||
return true, nil, err
|
||||
}
|
||||
ids = make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.MediaItemID)
|
||||
}
|
||||
return true, ids, nil
|
||||
case calendarFilterTrending:
|
||||
if h.trending == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
snap, ok, err := h.trending.Get(ctx, calendarTrendingSnapshotSource, calendarTrendingSnapshotWindow)
|
||||
if err != nil {
|
||||
return true, nil, err
|
||||
}
|
||||
if !ok {
|
||||
return true, nil, nil
|
||||
}
|
||||
return true, snap.ContentIDs, nil
|
||||
default:
|
||||
return false, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWatched decorates events with the profile's completed status. Best-effort:
|
||||
// a lookup failure logs and returns no watched marks rather than failing the page.
|
||||
func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessFilter, events []catalog.CalendarEvent) map[string]bool {
|
||||
if h.personal == nil || len(events) == 0 {
|
||||
return map[string]bool{}
|
||||
}
|
||||
ids := make([]string, 0, len(events))
|
||||
for _, ev := range events {
|
||||
ids = append(ids, ev.ContentID)
|
||||
}
|
||||
watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "calendar watched overlay failed", "error", err)
|
||||
return map[string]bool{}
|
||||
}
|
||||
return watched
|
||||
}
|
||||
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location, watched map[string]bool) []calendarDayResponse {
|
||||
if len(events) == 0 {
|
||||
return []calendarDayResponse{}
|
||||
}
|
||||
@@ -241,6 +360,7 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv
|
||||
LocalAirDate: item.localDate,
|
||||
PosterURL: posterURLs[ev.PosterPath],
|
||||
PosterThumbhash: ev.PosterThumbhash,
|
||||
Watched: watched[ev.ContentID],
|
||||
Badges: badges,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
)
|
||||
|
||||
type stubCalendarRepo struct {
|
||||
@@ -422,3 +424,162 @@ func ptrString(value string) *string {
|
||||
func ptrInt(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
type stubCalendarPersonal struct {
|
||||
followed []string
|
||||
favorites []string
|
||||
watchlist []string
|
||||
watched map[string]bool
|
||||
lastWatchedIDs []string
|
||||
}
|
||||
|
||||
func (s *stubCalendarPersonal) ListFollowedItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.followed, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListFavoriteItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.favorites, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListWatchlistItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.watchlist, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListWatchedItemIDs(_ context.Context, _ int, _ string, ids []string) (map[string]bool, error) {
|
||||
s.lastWatchedIDs = ids
|
||||
return s.watched, nil
|
||||
}
|
||||
|
||||
type stubPopularSource struct{ items []recommendations.ScoredItem }
|
||||
|
||||
func (s *stubPopularSource) GetRecommendationCache(_ context.Context, _ int, _, _, _ string) ([]recommendations.ScoredItem, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
type stubTrendingSource struct {
|
||||
snap sections.TrendingSnapshot
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (s *stubTrendingSource) Get(_ context.Context, _, _ string) (sections.TrendingSnapshot, bool, error) {
|
||||
return s.snap, s.ok, nil
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_FollowingPassesFollowedIDs(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: []string{"s1", "s2"}}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !repo.last.RestrictByIDs {
|
||||
t.Fatalf("expected RestrictByIDs true")
|
||||
}
|
||||
if got := repo.last.RestrictToIDs; len(got) != 2 || got[0] != "s1" || got[1] != "s2" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [s1 s2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_PopularReadsCache(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{
|
||||
repo: repo,
|
||||
popular: &stubPopularSource{items: []recommendations.ScoredItem{{MediaItemID: "p1"}, {MediaItemID: "p2"}}},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=popular", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 2 || repo.last.RestrictToIDs[0] != "p1" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [p1 p2]", repo.last.RestrictToIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_TrendingReadsSnapshot(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{
|
||||
repo: repo,
|
||||
trending: &stubTrendingSource{ok: true, snap: sections.TrendingSnapshot{ContentIDs: []string{"t1"}}},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=trending", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 1 || repo.last.RestrictToIDs[0] != "t1" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [t1]", repo.last.RestrictToIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_EverythingHasNoRestriction(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if repo.last.RestrictByIDs {
|
||||
t.Fatalf("expected no restriction for everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_RejectsUnknownFilter(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=bogus", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if repo.calls != 0 {
|
||||
t.Fatalf("expected repo not called, got %d", repo.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_EmptyFollowedShortCircuits(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: nil}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if repo.calls != 0 {
|
||||
t.Fatalf("expected ListEvents skipped for empty followed set, got %d calls", repo.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_MarksWatchedItems(t *testing.T) {
|
||||
repo := &stubCalendarRepo{events: []catalog.CalendarEvent{
|
||||
{ContentID: "ep-1", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)},
|
||||
{ContentID: "ep-2", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)},
|
||||
}}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{watched: map[string]bool{"ep-1": true}}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
watched := map[string]bool{}
|
||||
for _, day := range resp.Events {
|
||||
for _, item := range day.Items {
|
||||
watched[item.ContentID] = item.Watched
|
||||
}
|
||||
}
|
||||
if !watched["ep-1"] || watched["ep-2"] {
|
||||
t.Fatalf("watched flags = %v, want ep-1 true / ep-2 false", watched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,12 +789,9 @@ func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows(
|
||||
events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{
|
||||
Start: start,
|
||||
End: end,
|
||||
Filter: "all",
|
||||
AllowedLibraryIDs: filter.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: filter.DisabledLibraryIDs,
|
||||
MaxContentRating: filter.MaxContentRating,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
})
|
||||
if err != nil {
|
||||
return discoverRows, err
|
||||
|
||||
@@ -1268,7 +1268,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
|
||||
if calendarRepo != nil {
|
||||
calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc)
|
||||
calendarPopular := recommendations.NewRepo(deps.DB)
|
||||
calendarTrending := sections.NewTrendingSnapshotRepository(deps.DB)
|
||||
calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc, calendarPopular, calendarTrending)
|
||||
r.With(apimw.RequireProfile).Get("/calendar", calendarHandler.HandleGetCalendar)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,17 @@ type CalendarEvent struct {
|
||||
type CalendarFilter struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Filter string // "all" | "favorites" | "watchlist"
|
||||
LibraryID *int
|
||||
AllowedLibraryIDs []int
|
||||
DisabledLibraryIDs []int
|
||||
MaxContentRating string
|
||||
UserID int
|
||||
ProfileID string
|
||||
|
||||
// RestrictByIDs limits results to items whose movie content_id (movies) or
|
||||
// series_id (episodes / season premieres) is in RestrictToIDs. When
|
||||
// RestrictByIDs is true and RestrictToIDs is empty, no rows match. Callers
|
||||
// resolve the id-set (Following / Popular / Trending) before querying.
|
||||
RestrictByIDs bool
|
||||
RestrictToIDs []string
|
||||
}
|
||||
|
||||
// CalendarRepository provides calendar queries across movies, episodes, and seasons.
|
||||
@@ -102,9 +106,18 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a
|
||||
args = append(args, f.End)
|
||||
argIdx++
|
||||
|
||||
movieBranch := r.buildMovieBranch(startArg, endArg, f, &args, &argIdx)
|
||||
filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, f, &args, &argIdx)
|
||||
filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, f, &args, &argIdx)
|
||||
// Optional id-set restriction, appended once and shared by all branches.
|
||||
// restrictArg stays 0 when the set is empty so branches short-circuit.
|
||||
restrictArg := 0
|
||||
if f.RestrictByIDs && len(f.RestrictToIDs) > 0 {
|
||||
restrictArg = argIdx
|
||||
args = append(args, f.RestrictToIDs)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
movieBranch := r.buildMovieBranch(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
episodeBranch := r.buildEpisodeBranch()
|
||||
seasonBranch := r.buildSeasonBranch()
|
||||
|
||||
@@ -139,14 +152,14 @@ FROM (
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildMovieBranch(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
"mi.type = 'movie'",
|
||||
fmt.Sprintf("mi.release_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
}
|
||||
r.appendLibraryExistsClauses("mi.content_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("mi.content_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("mi.content_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type,
|
||||
mi.title, NULL::text AS episode_title, NULL::text AS series_id,
|
||||
@@ -158,14 +171,14 @@ FROM media_items mi
|
||||
WHERE %s`, strings.Join(conditions, " AND "))
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
fmt.Sprintf("e.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
"e.season_number > 0", // exclude specials
|
||||
}
|
||||
r.appendLibraryExistsClauses("e.series_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("e.series_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("e.series_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number,
|
||||
e.episode_number, e.title AS episode_title, e.air_date,
|
||||
@@ -188,14 +201,14 @@ FROM filtered_episodes fe
|
||||
LEFT JOIN season_finales sf ON sf.series_id = fe.series_id AND sf.season_number = fe.season_number`
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
fmt.Sprintf("s.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
"s.season_number > 0", // exclude specials
|
||||
}
|
||||
r.appendLibraryExistsClauses("s.series_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("s.series_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number,
|
||||
s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone,
|
||||
@@ -282,30 +295,16 @@ func (r *CalendarRepository) appendLibraryExistsClauses(contentIDExpr string, f
|
||||
*argIdx++
|
||||
}
|
||||
|
||||
// appendPersonalFilterClause adds favorites/watchlist EXISTS subqueries.
|
||||
func (r *CalendarRepository) appendPersonalFilterClause(itemIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) {
|
||||
switch f.Filter {
|
||||
case "favorites":
|
||||
userArg := *argIdx
|
||||
*args = append(*args, f.UserID)
|
||||
*argIdx++
|
||||
profileArg := *argIdx
|
||||
*args = append(*args, f.ProfileID)
|
||||
*argIdx++
|
||||
*conditions = append(*conditions, fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM user_favorites uf WHERE uf.user_id = $%d AND uf.profile_id = $%d AND uf.media_item_id = %s)",
|
||||
userArg, profileArg, itemIDExpr,
|
||||
))
|
||||
case "watchlist":
|
||||
userArg := *argIdx
|
||||
*args = append(*args, f.UserID)
|
||||
*argIdx++
|
||||
profileArg := *argIdx
|
||||
*args = append(*args, f.ProfileID)
|
||||
*argIdx++
|
||||
*conditions = append(*conditions, fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM user_watchlist uw WHERE uw.user_id = $%d AND uw.profile_id = $%d AND uw.media_item_id = %s)",
|
||||
userArg, profileArg, itemIDExpr,
|
||||
))
|
||||
// appendRestrictClause limits a branch to the id-set in CalendarFilter. restrictArg
|
||||
// is the positional parameter holding the id array (0 when the set is empty). An
|
||||
// empty restriction matches nothing.
|
||||
func (r *CalendarRepository) appendRestrictClause(itemIDExpr string, restrictArg int, f CalendarFilter, conditions *[]string) {
|
||||
if !f.RestrictByIDs {
|
||||
return
|
||||
}
|
||||
if restrictArg == 0 {
|
||||
*conditions = append(*conditions, "1 = 0")
|
||||
return
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf("%s = ANY($%d)", itemIDExpr, restrictArg))
|
||||
}
|
||||
|
||||
@@ -102,3 +102,47 @@ func TestBuildListEventsQuery_RejectsExplicitLibraryOutsideAllowedScope(t *testi
|
||||
t.Fatalf("expected season branch to short-circuit inaccessible library selection, got:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListEventsQuery_AppliesIDRestriction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &CalendarRepository{}
|
||||
query, args := repo.buildListEventsQuery(CalendarFilter{
|
||||
Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC),
|
||||
RestrictByIDs: true,
|
||||
RestrictToIDs: []string{"series-1", "movie-2"},
|
||||
})
|
||||
|
||||
for _, fragment := range []string{
|
||||
"mi.content_id = ANY($3)",
|
||||
"e.series_id = ANY($3)",
|
||||
"s.series_id = ANY($3)",
|
||||
} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
t.Fatalf("expected query to contain %q, got:\n%s", fragment, query)
|
||||
}
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected start/end/ids args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListEventsQuery_EmptyRestrictionMatchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &CalendarRepository{}
|
||||
query, args := repo.buildListEventsQuery(CalendarFilter{
|
||||
Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC),
|
||||
RestrictByIDs: true,
|
||||
RestrictToIDs: nil,
|
||||
})
|
||||
|
||||
if strings.Count(query, "1 = 0") != 3 {
|
||||
t.Fatalf("expected each branch to short-circuit with 1 = 0, got:\n%s", query)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected only start/end args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CalendarEvent } from "@/hooks/queries/calendar";
|
||||
export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const thumbhashUrl = event.poster_thumbhash ? decodeThumbhash(event.poster_thumbhash) : "";
|
||||
const watched = event.watched === true;
|
||||
|
||||
const href =
|
||||
event.type === "movie"
|
||||
@@ -25,7 +26,7 @@ export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
|
||||
<div className="media-card group/card">
|
||||
<ViewTransitionLink to={href} className="block overflow-hidden rounded-xl">
|
||||
<div
|
||||
className="media-card-image relative aspect-[2/3]"
|
||||
className={`media-card-image relative aspect-[2/3] ${watched ? "opacity-60 grayscale" : ""}`}
|
||||
style={
|
||||
thumbhashUrl
|
||||
? {
|
||||
@@ -62,10 +63,24 @@ export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{watched && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className="bg-background/70 text-foreground flex h-8 w-8 items-center justify-center rounded-full text-base backdrop-blur-sm"
|
||||
aria-label="Watched"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ViewTransitionLink>
|
||||
<ViewTransitionLink to={href} className="block px-1 pt-3">
|
||||
<div className="truncate text-[14px] font-semibold tracking-tight">{event.title}</div>
|
||||
<div
|
||||
className={`truncate text-[14px] font-semibold tracking-tight ${watched ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
|
||||
{subtitle}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CalendarEvent {
|
||||
local_air_date: string;
|
||||
poster_url?: string;
|
||||
poster_thumbhash?: string;
|
||||
watched?: boolean;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("Calendar page", () => {
|
||||
renderCalendar("/calendar?week=2026-04-06&library=7");
|
||||
|
||||
expect(mockUseCalendarWeek).toHaveBeenCalledWith("2026-04-06", {
|
||||
filter: "all",
|
||||
filter: "following",
|
||||
libraryId: 7,
|
||||
});
|
||||
});
|
||||
|
||||
+88
-21
@@ -16,26 +16,50 @@ import { useUserLibraries } from "@/hooks/queries/libraries";
|
||||
import WeekNavigator from "@/components/calendar/WeekNavigator";
|
||||
import DayGroup from "@/components/calendar/DayGroup";
|
||||
import { addWeeks, formatDayHeading, getWeekDays, getWeekStart } from "@/lib/calendarWeek";
|
||||
import { storage } from "@/utils/storage";
|
||||
|
||||
type CalendarFilter = "all" | "favorites" | "watchlist";
|
||||
type CalendarFilter = "following" | "popular" | "trending" | "everything";
|
||||
|
||||
const FILTER_OPTIONS: { value: CalendarFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "favorites", label: "Favorites" },
|
||||
{ value: "watchlist", label: "Watchlist" },
|
||||
// "popular" (server-wide most-watched) is hidden for now — it's sparse until the
|
||||
// server has enough watch history. Re-add the entry here and in KNOWN_FILTERS to
|
||||
// surface it; the backend filter remains supported.
|
||||
const PRESET_OPTIONS: { value: CalendarFilter; label: string }[] = [
|
||||
{ value: "following", label: "Following" },
|
||||
{ value: "trending", label: "Trending" },
|
||||
{ value: "everything", label: "All" },
|
||||
];
|
||||
|
||||
const DEFAULT_PRESET: CalendarFilter = "following";
|
||||
|
||||
// The selectable presets plus legacy server values so old shared links keep working.
|
||||
const KNOWN_FILTERS = new Set<string>([
|
||||
...PRESET_OPTIONS.map((o) => o.value),
|
||||
"all",
|
||||
"favorites",
|
||||
"watchlist",
|
||||
]);
|
||||
|
||||
/** Skeleton rows mirror a full week; enough slides per row to fill wide viewports. */
|
||||
const CALENDAR_SKELETON_DAY_ROWS = 7;
|
||||
const CALENDAR_SKELETON_ITEMS_PER_ROW = 18;
|
||||
|
||||
function readStoredPreset(): CalendarFilter {
|
||||
const stored = storage.get(storage.KEYS.CALENDAR_PRESET);
|
||||
return stored && PRESET_OPTIONS.some((o) => o.value === stored)
|
||||
? (stored as CalendarFilter)
|
||||
: DEFAULT_PRESET;
|
||||
}
|
||||
|
||||
function writeStoredPreset(value: string) {
|
||||
storage.set(storage.KEYS.CALENDAR_PRESET, value);
|
||||
}
|
||||
|
||||
function parseCalendarParams(searchParams: URLSearchParams) {
|
||||
const weekRaw = searchParams.get("week");
|
||||
const weekStart =
|
||||
weekRaw && /^\d{4}-\d{2}-\d{2}$/.test(weekRaw) ? weekRaw : getWeekStart(new Date());
|
||||
const rawFilter = searchParams.get("filter");
|
||||
const filter: CalendarFilter =
|
||||
rawFilter === "favorites" || rawFilter === "watchlist" ? rawFilter : "all";
|
||||
const filter = rawFilter && KNOWN_FILTERS.has(rawFilter) ? rawFilter : readStoredPreset();
|
||||
const libraryIdRaw = searchParams.get("library");
|
||||
const libraryId = libraryIdRaw ? Number(libraryIdRaw) : undefined;
|
||||
return { weekStart, filter, libraryId };
|
||||
@@ -77,7 +101,10 @@ export default function Calendar() {
|
||||
}
|
||||
|
||||
const goToWeek = (w: string) => setParams({ week: w });
|
||||
const setFilter = (f: string) => setParams({ filter: f === "all" ? undefined : f });
|
||||
const setFilter = (f: string) => {
|
||||
writeStoredPreset(f);
|
||||
setParams({ filter: f === DEFAULT_PRESET ? undefined : f });
|
||||
};
|
||||
const setLibrary = (id: string) => setParams({ library: id === "all" ? undefined : id });
|
||||
|
||||
const onSelectDay = (date: string) => {
|
||||
@@ -101,13 +128,13 @@ export default function Calendar() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Calendar</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Filter toggle */}
|
||||
{/* Preset pills (desktop) */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filter"
|
||||
className="surface-panel-subtle flex items-center gap-0.5 rounded-full p-1"
|
||||
aria-label="Calendar preset"
|
||||
className="surface-panel-subtle hidden items-center gap-0.5 rounded-full p-1 lg:flex"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
{PRESET_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
@@ -124,6 +151,22 @@ export default function Calendar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preset dropdown (smaller displays) */}
|
||||
<div className="lg:hidden">
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="border-border/50 bg-surface/60 h-9 w-auto min-w-[130px] rounded-full text-[12px] font-semibold backdrop-blur-sm sm:text-[13px]">
|
||||
<SelectValue placeholder="Following" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESET_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Library filter */}
|
||||
{libraries.data && libraries.data.length > 1 && (
|
||||
<Select value={libraryId ? String(libraryId) : "all"} onValueChange={setLibrary}>
|
||||
@@ -186,7 +229,7 @@ export default function Calendar() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
|
||||
<CalendarEmpty filter={filter} onClearFilter={() => setFilter("all")} />
|
||||
<CalendarEmpty filter={filter} onSelectPreset={setFilter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -210,19 +253,43 @@ function CalendarSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEmpty({ filter, onClearFilter }: { filter: string; onClearFilter: () => void }) {
|
||||
function CalendarEmpty({
|
||||
filter,
|
||||
onSelectPreset,
|
||||
}: {
|
||||
filter: string;
|
||||
onSelectPreset: (f: string) => void;
|
||||
}) {
|
||||
const isEverything = filter === "everything" || filter === "all";
|
||||
return (
|
||||
<div className="surface-panel flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-[1.8rem] border-0 px-6 py-16 text-center">
|
||||
<CalendarDays className="text-muted-foreground h-10 w-10" strokeWidth={1.5} />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{filter !== "all"
|
||||
? `No events this week matching your ${filter} filter.`
|
||||
: "Nothing scheduled this week."}
|
||||
{filter === "following"
|
||||
? "Nothing upcoming from shows you follow this week."
|
||||
: isEverything
|
||||
? "Nothing scheduled this week."
|
||||
: "No events this week for this view."}
|
||||
</p>
|
||||
{filter !== "all" && (
|
||||
<Button variant="link" size="sm" className="text-primary text-sm" onClick={onClearFilter}>
|
||||
Show all
|
||||
</Button>
|
||||
{!isEverything && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-primary text-sm"
|
||||
onClick={() => onSelectPreset("trending")}
|
||||
>
|
||||
Trending
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-primary text-sm"
|
||||
onClick={() => onSelectPreset("everything")}
|
||||
>
|
||||
Show everything
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ const STORAGE_KEYS = {
|
||||
UI_HIGH_CONTRAST: "silo-ui-high-contrast",
|
||||
UI_CUSTOM_THEME_VARS: "silo-custom-theme-vars",
|
||||
UI_CUSTOM_CSS: "silo-custom-css",
|
||||
CALENDAR_PRESET: "calendar:preset",
|
||||
} as const;
|
||||
|
||||
type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
|
||||
|
||||
Reference in New Issue
Block a user