perf(dashboard): per-rail skeletons + drop CAST that blocked content index

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<number>\`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) <noreply@anthropic.com>
Entire-Checkpoint: 1e3392724bc8
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-02 09:09:10 +02:00
1 parent 31bddbd70f
commit d67150d38f
2 files changed
+86 -36

No files matched your search

@@ -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<number>`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);
}
@@ -3,46 +3,17 @@
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
@for (_ of skeletonRails; track $index) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
} @else if (!hasPlaylists()) {
@if (data.playlistsLoaded() && !hasPlaylists()) {
<app-empty-state
[type]="'welcome-dashboard'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- Hero: real card if a recent item resolved, skeleton while still
loading the first global recent item, nothing if no recent items
exist. Each block below renders as soon as ITS data is ready —
no longer pinned on the slowest rail's I/O. -->
@if (hero(); as item) {
<a
class="hero"
@@ -113,8 +84,25 @@
</span>
</span>
</a>
} @else if (data.globalRecentLoading()) {
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
}
<!-- Recently watched rail -->
@if (recentlyWatchedCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
@@ -123,8 +111,22 @@
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
/>
} @else if (data.globalRecentLoading()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- Favorites rail -->
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
@@ -133,8 +135,22 @@
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
} @else if (data.globalFavoritesLoading()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- Sources rail -->
@if (sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
@@ -145,8 +161,22 @@
[testId]="'dashboard-recent-sources-rail'"
(actionSelected)="onSourceActionSelected($event)"
/>
} @else if (!data.playlistsLoaded()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- Xtream recently-added rail (only relevant if there are Xtream playlists) -->
@if (xtreamRecentlyAddedCards().length > 0) {
<lib-dashboard-rail
[label]="
@@ -156,6 +186,21 @@
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
} @else if (
xtreamPlaylistCount() > 0 && data.xtreamRecentlyAddedLoading()
) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
}
</section>