From d67150d38fab0a34ba103b9cceb39c7ca27840e7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 2 May 2026 09:09:10 +0200 Subject: [PATCH] perf(dashboard): per-rail skeletons + drop CAST that blocked content index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes that compound: the dashboard now renders progressively as each rail's data resolves, and the slowest of those rails (Xtream recently-added) no longer scans the entire content table. Per-rail skeletons (template restructure): The old @if (!ready()) gate hid the whole dashboard until ALL FOUR loading flags resolved (playlistsLoaded, globalRecentLoaded, globalFavoritesLoaded, xtreamRecentlyAddedLoaded). The slowest one pinned the entire skeleton up for the full tail latency — visibly seconds of "loading" even when 3 of 4 rails could have rendered immediately. Replace with per-rail conditionals: - Hero: renders the moment globalRecentItems[0] is available; skeleton shows only while data.globalRecentLoading() is true and no item exists. - Each rail: shows real content if its cards are non-empty, its own skeleton if its dedicated loading flag is true, nothing otherwise. - The Xtream recently-added rail's skeleton is gated on having Xtream playlists at all, so M3U-only users never see a skeleton for it. The loading signals were already exposed on DashboardDataService (globalRecentLoading, globalFavoritesLoading, xtreamRecentlyAddedLoading) but went unused because of the monolithic gate. Same skeleton markup is reused per rail; no styling changes. Drop CAST(added AS INTEGER) in getGlobalRecentlyAdded: The query ordered by sql\`CAST(content.added AS INTEGER)\`. SQLite cannot use an index on a column wrapped in a function, so the existing idx_content_type_added index was bypassed and the planner did a full table scan + sort on content (10k–100k+ rows for a typical Xtream catalog) on every dashboard load. Sort by schema.content.added directly. Xtream stores Unix-epoch timestamps as 10-digit numeric strings (anything since 2001-09-09), so lexicographic and numeric sort are equivalent. The (type, added) index now drives the ORDER BY too — full table scan becomes an index range scan + LIMIT 20. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 1e3392724bc8 --- .../database/operations/content.operations.ts | 9 +- .../workspace-dashboard-rails.component.html | 113 ++++++++++++------ 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 3853cec71..444d4aecc 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -124,7 +124,6 @@ export async function getGlobalRecentlyAdded( const normalizedLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 200; - const addedOrder = sql`CAST(${schema.content.added} AS INTEGER)`; const whereConditions = [ inArray(schema.content.type, contentTypes), @@ -136,6 +135,12 @@ export async function getGlobalRecentlyAdded( whereConditions.push(eq(schema.playlists.type, playlistType)); } + // Sort by `added` directly. Xtream stores Unix-epoch timestamps as + // 10-digit numeric strings (since 2001-09-09), so lexicographic sort + // is equivalent to numeric sort. Wrapping the column in CAST(... AS + // INTEGER) — as we used to — blocks SQLite from using + // idx_content_type_added and forces a full table scan + sort on the + // entire content table (often 100k+ rows) on every dashboard load. return db .select({ ...selectContentFields(), @@ -153,7 +158,7 @@ export async function getGlobalRecentlyAdded( eq(schema.categories.playlistId, schema.playlists.id) ) .where(and(...whereConditions)) - .orderBy(desc(addedOrder)) + .orderBy(desc(schema.content.added)) .limit(normalizedLimit); } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index abc1c3a7a..9555f7741 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -3,46 +3,17 @@ [class.rails-page--empty]="ready() && !hasPlaylists()" data-test-id="dashboard-page" > - @if (!ready()) { - - - - @for (_ of skeletonRails; track $index) { - - } - } @else if (!hasPlaylists()) { + @if (data.playlistsLoaded() && !hasPlaylists()) { } @else { + @if (hero(); as item) { + } @else if (data.globalRecentLoading()) { + } + @if (recentlyWatchedCards().length > 0) { + } @else if (data.globalRecentLoading()) { + } + @if (favoriteCards().length > 0) { + } @else if (data.globalFavoritesLoading()) { + } + @if (sourceCards().length > 0) { + } @else if (!data.playlistsLoaded()) { + } + @if (xtreamRecentlyAddedCards().length > 0) { + } @else if ( + xtreamPlaylistCount() > 0 && data.xtreamRecentlyAddedLoading() + ) { + } }