feat(store): order video list by published_at, NULLS LAST

ListVideos now sorts summarized-first, then published_at DESC with undated
videos last, then seen_at DESC as a tiebreak (was seen_at only). Aligns the
list with the recency framing — newest content surfaces first — so the
recency-bounded feed reads coherently (UX review B2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:50:30 +02:00
co-authored by Claude Opus 4.8
parent 2384c47b81
commit 3df0459fed
2 changed files with 40 additions and 3 deletions
+6 -3
View File
@@ -141,8 +141,11 @@ const selectVideo = `
FROM videos v
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
// ListVideos returns ALL of the user's videos — summarized first then most recent
// by seen_at — capped at limit (non-positive defaults to 500). Unsummarized
// ListVideos returns ALL of the user's videos — summarized first, then by
// published_at DESC with undated videos last, then seen_at DESC as a tiebreak —
// capped at limit (non-positive defaults to 500). The published_at ordering
// aligns the list with the recency framing (newest content first); seen_at
// breaks ties and orders same/!undated rows deterministically. Unsummarized
// videos come back with Summarized=false and empty summary fields, so the list
// view can render them with a "Summarize" affordance. Scoped by user_id.
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
@@ -154,7 +157,7 @@ func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]Sum
rows, err := tx.Query(ctx,
selectVideo+`
WHERE v.user_id = $1
ORDER BY (s.id IS NOT NULL) DESC, v.seen_at DESC
ORDER BY (s.id IS NOT NULL) DESC, v.published_at DESC NULLS LAST, v.seen_at DESC
LIMIT $2`,
userID, limit)
if err != nil {
@@ -3,6 +3,7 @@ package store_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
@@ -125,6 +126,39 @@ func TestListVideosReturnsSummarizedAndUnsummarized(t *testing.T) {
require.True(t, byID[videoY].SummarizeRequested, "queued video carries the flag")
}
// TestListVideosOrderedByPublishedDescNullsLast: summarized videos sort first
// (regardless of their date), then unsummarized by published_at DESC with
// undated (NULL) videos last — the recency-aligned list order (UX review B2).
func TestListVideosOrderedByPublishedDescNullsLast(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
const (
vSummOld = "cccccccc-cccc-cccc-cccc-cccccccccccc" // summarized, oldest date
vNewer = "dddddddd-dddd-dddd-dddd-dddddddddddd" // unsummarized, newest
vOlder = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" // unsummarized, older
vUndated = "ffffffff-ffff-ffff-ffff-ffffffffffff" // unsummarized, no date
)
// Deliver first so the userA row exists (the videos FK needs it); the
// summarized video carries the OLDEST date yet must still sort first because
// it is summarized, proving summarized-first dominates the date sort.
require.NoError(t, s.Deliver(ctx, summary(userA, vSummOld, "body")))
seedVideo(t, p, userA, vSummOld, "Summarized Old", "youtube", "https://s", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
seedVideo(t, p, userA, vNewer, "Newer", "youtube", "https://n", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
seedVideo(t, p, userA, vOlder, "Older", "youtube", "https://o", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
seedVideo(t, p, userA, vUndated, "Undated", "youtube", "https://u", time.Time{})
rows, err := s.ListVideos(ctx, userA, 50)
require.NoError(t, err)
require.Len(t, rows, 4)
got := []string{rows[0].VideoID, rows[1].VideoID, rows[2].VideoID, rows[3].VideoID}
require.Equal(t, []string{vSummOld, vNewer, vOlder, vUndated}, got,
"summarized first, then published_at DESC, NULL dates last")
}
func TestListVideosIsUserScoped(t *testing.T) {
ctx := context.Background()
s := newStore(t)