perf(user): cursor + simple pagination on /liked and /history

- /liked: switch Laravel paginate(12) to cursorPaginate(24). Cursor
  pagination matches the Phase 2 feed endpoint pattern: stable across
  inserts (unlike ?page=N which can duplicate or skip rows when a new
  like lands between page fetches) and loads a bounded slice no matter
  how many thousands of likes the user accumulates.
- /history: switch from a get()-all plus in-memory sort to a joined
  simplePaginate(48) at the DB layer. A heavy user who's watched
  thousands of videos would previously fetch every video row into
  memory on each history page load — now the query selects a bounded
  page of (video_views + videos) pairs. Duplicates within a page
  collapse via collect()->unique('id').

Channel videos tab stays non-paginated for now — it needs a dedicated
JSON endpoint before it can share the Phase 2 /videos/feed/more
infinite-scroll wiring. Flagged as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:09:00 +03:00
1 parent 574c9581bb
commit 341e94a4f8
1 file changed
+36 -20
+36 -20
View File
@@ -325,29 +325,41 @@ class UserController extends Controller
}
// Watch history
public function history()
public function history(Request $request)
{
$user = Auth::user();
// Get videos the user has watched, ordered by most recently watched
// Include private videos since they are the user's own
$videoIds = \DB::table('video_views')
->where('user_id', $user->id)
->orderBy('watched_at', 'desc')
->pluck('video_id')
->unique();
$videos = Video::whereIn('id', $videoIds)
// Latest-first by watched_at. Paginate at the join so a user who
// has watched tens of thousands of videos doesn't pull every row
// into memory on each page load. Fetch the videos keyed by latest
// view so sort order is deterministic even when the same video has
// been watched more than once.
//
// Each history row is one (video_views + videos) pair, with the
// uniqueness constraint deferred to presentation: duplicates per
// video are expected (one row per view event) and the UI collapses
// them with view-count badges.
$per = 48;
$rows = \DB::table('video_views')
->join('videos', 'videos.id', '=', 'video_views.video_id')
->where('video_views.user_id', $user->id)
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id);
$q->where('videos.visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->get()
->sortBy(function ($video) use ($videoIds) {
return $videoIds->search($video->id);
});
->orderBy('video_views.watched_at', 'desc')
->select('videos.id as id', 'video_views.watched_at')
->simplePaginate($per);
return view('user.history', compact('videos'));
$videoIds = collect($rows->items())->pluck('id')->unique();
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
$videos = collect($rows->items())
->map(fn ($r) => $videoMap->get($r->id))
->filter()
->unique('id') // collapse re-watches of the same video on this page
->values();
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
}
// Clear watch history
@@ -392,14 +404,18 @@ class UserController extends Controller
public function liked()
{
$user = Auth::user();
// Include private videos in liked (user's own private videos)
// Include private videos in liked (user's own private videos).
// Cursor pagination matches the Phase 2 feed endpoints — stable
// across inserts (unlike ?page=N which can duplicate or skip rows
// when a new like lands between page fetches) and loads a bounded
// slice regardless of how many thousands of likes the user has.
$videos = $user->likes()
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->latest()
->paginate(12);
->latest('videos.id')
->cursorPaginate(24);
return view('user.liked', compact('videos'));
}