diff --git a/internal/adapters/store/reads.go b/internal/adapters/store/reads.go index 1be3b52..6db068c 100644 --- a/internal/adapters/store/reads.go +++ b/internal/adapters/store/reads.go @@ -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 { diff --git a/internal/adapters/store/summarize_mode_test.go b/internal/adapters/store/summarize_mode_test.go index 4f2284d..00194a3 100644 --- a/internal/adapters/store/summarize_mode_test.go +++ b/internal/adapters/store/summarize_mode_test.go @@ -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)