From d94ec16fae947d4305cfe75d60c51b0b1877480b Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 18 Mar 2026 22:04:19 -0400 Subject: [PATCH] feat(web): add route loaders to prefetch dashboard, explore, and settings queries --- AGENTS.md | 2 +- apps/server/src/cron.ts | 4 + apps/server/src/index.ts | 1 + apps/web/package.json | 1 - .../components/dashboard/stats-display.tsx | 10 +- .../src/components/explore/explore-client.tsx | 140 ---------------- apps/web/src/lib/orpc/client.ts | 7 + apps/web/src/routes/_app/dashboard.tsx | 28 ++++ apps/web/src/routes/_app/explore.tsx | 156 +++++++++++++++++- apps/web/src/routes/_app/settings.tsx | 36 ++++ bun.lock | 1 - packages/core/src/backup.ts | 5 +- packages/db/package.json | 1 - packages/db/src/client.ts | 13 ++ packages/db/src/queries/discovery.ts | 22 ++- packages/db/src/queries/tracking.ts | 57 +++---- packages/db/src/utils.ts | 7 - 17 files changed, 287 insertions(+), 204 deletions(-) delete mode 100644 apps/web/src/components/explore/explore-client.tsx delete mode 100644 packages/db/src/utils.ts diff --git a/AGENTS.md b/AGENTS.md index 959dd7c..99eb7dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ Cross-package imports: - `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types - `@sofa/db/queries/*` — Query layer (e.g., `@sofa/db/queries/tracking`, `@sofa/db/queries/metadata`) -- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/utils`, `@sofa/db/migrate`, `@sofa/db/test-utils` +- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/migrate`, `@sofa/db/test-utils` - `@sofa/tmdb/client`, `@sofa/tmdb/image` - `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc. - `@sofa/auth/server`, `@sofa/auth/config` diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index d74a4d3..b776d68 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -298,6 +298,10 @@ export function startJobs() { schedule("telemetryReport", "30 0 * * *", async () => { await performTelemetryReport(); }); + schedule("optimizeDb", "0 4 * * 0", async () => { + const { optimizeDatabase } = await import("@sofa/db/client"); + optimizeDatabase(); + }); log.info(`Started ${jobs.size} jobs`); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9003833..50fdb15 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,7 @@ app.use( cors({ origin: process.env.CORS_ORIGIN || "http://localhost:3000", credentials: true, + maxAge: 86400, }), ); diff --git a/apps/web/package.json b/apps/web/package.json index ddad2ed..1bc40b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,7 +34,6 @@ "clsx": "2.1.1", "cmdk": "1.1.1", "jotai": "2.18.1", - "media-chrome": "4.18.1", "motion": "12.38.0", "react": "catalog:", "react-dom": "catalog:", diff --git a/apps/web/src/components/dashboard/stats-display.tsx b/apps/web/src/components/dashboard/stats-display.tsx index 1fd5f7c..54cc1d6 100644 --- a/apps/web/src/components/dashboard/stats-display.tsx +++ b/apps/web/src/components/dashboard/stats-display.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { Suspense, lazy, useState } from "react"; import { Select, @@ -14,7 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas"; -import { Sparkline } from "./sparkline"; +const Sparkline = lazy(() => import("./sparkline").then((m) => ({ default: m.Sparkline }))); function StatCardSkeleton() { return ( @@ -66,7 +66,11 @@ function StatCard({ className="animate-stagger-item border-border/30 bg-card/50 relative overflow-hidden rounded-xl border p-4" style={{ "--stagger-index": index } as React.CSSProperties} > - {sparklineData && } + {sparklineData && ( + + + + )}
diff --git a/apps/web/src/components/explore/explore-client.tsx b/apps/web/src/components/explore/explore-client.tsx deleted file mode 100644 index 98bd532..0000000 --- a/apps/web/src/components/explore/explore-client.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useLingui } from "@lingui/react/macro"; -import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; - -import { Skeleton } from "@/components/ui/skeleton"; -import { orpc } from "@/lib/orpc/client"; - -import { FilterableTitleRow } from "./filterable-title-row"; -import { HeroBanner } from "./hero-banner"; -import { TitleRow } from "./title-row"; - -function ExploreSkeletons() { - return ( -
- - {[1, 2, 3].map((i) => ( -
-
- - -
-
- {Array.from({ length: 8 }).map((_, j) => ( -
- -
- ))} -
-
- ))} -
- ); -} - -function mergeMaps(...maps: (Record | undefined)[]): Record { - return Object.assign({}, ...maps.filter(Boolean)); -} - -export function ExploreClient() { - const { t } = useLingui(); - const { - data: trendingData, - isPending: trendingPending, - fetchNextPage: fetchNextTrending, - hasNextPage: hasNextTrending, - isFetchingNextPage: isFetchingNextTrending, - } = useInfiniteQuery( - orpc.explore.trending.infiniteOptions({ - input: (pageParam: number) => ({ type: "all" as const, page: pageParam }), - initialPageParam: 1, - getNextPageParam: (lastPage) => - lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, - }), - ); - - const { data: popularMoviesData, isPending: moviesPending } = useQuery( - orpc.explore.popular.queryOptions({ input: { type: "movie" } }), - ); - const { data: popularTvData, isPending: tvPending } = useQuery( - orpc.explore.popular.queryOptions({ input: { type: "tv" } }), - ); - const { data: movieGenreData } = useQuery( - orpc.explore.genres.queryOptions({ input: { type: "movie" } }), - ); - const { data: tvGenreData } = useQuery( - orpc.explore.genres.queryOptions({ input: { type: "tv" } }), - ); - - const isPending = trendingPending || moviesPending || tvPending; - - const trendingItems = useMemo( - () => trendingData?.pages.flatMap((p) => p.items) ?? [], - [trendingData?.pages], - ); - - const hero = trendingData?.pages[0]?.hero ?? null; - - if (isPending) return ; - - // Merge user statuses and episode progress from all responses - const userStatuses = mergeMaps( - ...(trendingData?.pages.map((p) => p.userStatuses) ?? []), - popularMoviesData?.userStatuses, - popularTvData?.userStatuses, - ); - const episodeProgress = mergeMaps( - ...(trendingData?.pages.map((p) => p.episodeProgress) ?? []), - popularMoviesData?.episodeProgress, - popularTvData?.episodeProgress, - ); - - return ( -
- {hero && ( - - )} - -
- } - items={trendingItems} - userStatuses={userStatuses} - episodeProgress={episodeProgress} - onEndReached={fetchNextTrending} - hasNextPage={hasNextTrending} - isFetchingNextPage={isFetchingNextTrending} - /> -
- - } - mediaType="movie" - defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)} - genres={movieGenreData?.genres ?? []} - userStatuses={userStatuses} - episodeProgress={episodeProgress} - /> - - } - mediaType="tv" - defaultItems={(popularTvData?.items ?? []).slice(0, 20)} - genres={tvGenreData?.genres ?? []} - userStatuses={userStatuses} - episodeProgress={episodeProgress} - /> -
- ); -} diff --git a/apps/web/src/lib/orpc/client.ts b/apps/web/src/lib/orpc/client.ts index 5074367..cbbaf73 100644 --- a/apps/web/src/lib/orpc/client.ts +++ b/apps/web/src/lib/orpc/client.ts @@ -10,6 +10,13 @@ import type { contract } from "@sofa/api/contract"; import { i18n } from "@sofa/i18n"; export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + gcTime: 10 * 60_000, + refetchOnWindowFocus: false, + }, + }, queryCache: new QueryCache({ onError: (_error, query) => { toast.error(i18n._(msg`Something went wrong…`), { diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx index bdaef49..95e040c 100644 --- a/apps/web/src/routes/_app/dashboard.tsx +++ b/apps/web/src/routes/_app/dashboard.tsx @@ -1,15 +1,43 @@ import { createFileRoute } from "@tanstack/react-router"; +import { ContinueWatchingSectionSkeleton } from "@/components/dashboard/continue-watching-list"; import { ContinueWatchingSection } from "@/components/dashboard/continue-watching-section"; import { LibrarySection } from "@/components/dashboard/library-section"; import { RecommendationsSection } from "@/components/dashboard/recommendations-section"; +import { StatsSectionSkeleton } from "@/components/dashboard/stats-display"; import { StatsSection } from "@/components/dashboard/stats-section"; +import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid"; import { WelcomeHeader } from "@/components/dashboard/welcome-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/dashboard")({ + loader: async ({ context }) => { + await Promise.all([ + context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()), + context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()), + context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()), + ]); + }, + pendingComponent: DashboardSkeleton, component: DashboardPage, }); +function DashboardSkeleton() { + return ( +
+
+ + +
+ + + + +
+ ); +} + function DashboardPage() { const { session } = Route.useRouteContext(); return ( diff --git a/apps/web/src/routes/_app/explore.tsx b/apps/web/src/routes/_app/explore.tsx index 0feb9f5..b9aa445 100644 --- a/apps/web/src/routes/_app/explore.tsx +++ b/apps/web/src/routes/_app/explore.tsx @@ -1,7 +1,159 @@ +import { useLingui } from "@lingui/react/macro"; +import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; -import { ExploreClient } from "@/components/explore/explore-client"; +import { FilterableTitleRow } from "@/components/explore/filterable-title-row"; +import { HeroBanner } from "@/components/explore/hero-banner"; +import { TitleRow } from "@/components/explore/title-row"; +import { Skeleton } from "@/components/ui/skeleton"; +import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/explore")({ - component: () => , + loader: ({ context }) => { + context.queryClient.ensureQueryData( + orpc.explore.popular.queryOptions({ input: { type: "movie" } }), + ); + context.queryClient.ensureQueryData( + orpc.explore.popular.queryOptions({ input: { type: "tv" } }), + ); + context.queryClient.ensureQueryData( + orpc.explore.genres.queryOptions({ input: { type: "movie" } }), + ); + context.queryClient.ensureQueryData( + orpc.explore.genres.queryOptions({ input: { type: "tv" } }), + ); + }, + pendingComponent: ExploreSkeletons, + component: ExplorePage, }); + +function ExploreSkeletons() { + return ( +
+ + {[1, 2, 3].map((i) => ( +
+
+ + +
+
+ {Array.from({ length: 8 }).map((_, j) => ( +
+ +
+ ))} +
+
+ ))} +
+ ); +} + +function mergeMaps(...maps: (Record | undefined)[]): Record { + return Object.assign({}, ...maps.filter(Boolean)); +} + +function ExplorePage() { + const { t } = useLingui(); + const { + data: trendingData, + isPending: trendingPending, + fetchNextPage: fetchNextTrending, + hasNextPage: hasNextTrending, + isFetchingNextPage: isFetchingNextTrending, + } = useInfiniteQuery( + orpc.explore.trending.infiniteOptions({ + input: (pageParam: number) => ({ type: "all" as const, page: pageParam }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, + }), + ); + + const { data: popularMoviesData, isPending: moviesPending } = useQuery( + orpc.explore.popular.queryOptions({ input: { type: "movie" } }), + ); + const { data: popularTvData, isPending: tvPending } = useQuery( + orpc.explore.popular.queryOptions({ input: { type: "tv" } }), + ); + const { data: movieGenreData } = useQuery( + orpc.explore.genres.queryOptions({ input: { type: "movie" } }), + ); + const { data: tvGenreData } = useQuery( + orpc.explore.genres.queryOptions({ input: { type: "tv" } }), + ); + + const isPending = trendingPending || moviesPending || tvPending; + + const trendingItems = useMemo( + () => trendingData?.pages.flatMap((p) => p.items) ?? [], + [trendingData?.pages], + ); + + const hero = trendingData?.pages[0]?.hero ?? null; + + if (isPending) return ; + + // Merge user statuses and episode progress from all responses + const userStatuses = mergeMaps( + ...(trendingData?.pages.map((p) => p.userStatuses) ?? []), + popularMoviesData?.userStatuses, + popularTvData?.userStatuses, + ); + const episodeProgress = mergeMaps( + ...(trendingData?.pages.map((p) => p.episodeProgress) ?? []), + popularMoviesData?.episodeProgress, + popularTvData?.episodeProgress, + ); + + return ( +
+ {hero && ( + + )} + +
+ } + items={trendingItems} + userStatuses={userStatuses} + episodeProgress={episodeProgress} + onEndReached={fetchNextTrending} + hasNextPage={hasNextTrending} + isFetchingNextPage={isFetchingNextTrending} + /> +
+ + } + mediaType="movie" + defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)} + genres={movieGenreData?.genres ?? []} + userStatuses={userStatuses} + episodeProgress={episodeProgress} + /> + + } + mediaType="tv" + defaultItems={(popularTvData?.items ?? []).slice(0, 20)} + genres={tvGenreData?.genres ?? []} + userStatuses={userStatuses} + episodeProgress={episodeProgress} + /> +
+ ); +} diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index b53b057..726956d 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -22,13 +22,49 @@ import { SystemHealthCards } from "@/components/settings/system-health-section"; import { UpdateCheckSection } from "@/components/settings/update-check-section"; import { TmdbLogo } from "@/components/tmdb-logo"; import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { orpc } from "@/lib/orpc/client"; const GITHUB_REPO = "jakejarvis/sofa"; export const Route = createFileRoute("/_app/settings")({ + loader: async ({ context }) => { + const promises: Promise[] = [ + context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()), + context.queryClient.ensureQueryData(orpc.system.status.queryOptions()), + ]; + const isAdmin = context.session.user.role === "admin"; + if (isAdmin) { + promises.push( + context.queryClient.ensureQueryData(orpc.admin.systemHealth.queryOptions()), + context.queryClient.ensureQueryData(orpc.admin.backups.list.queryOptions()), + context.queryClient.ensureQueryData(orpc.admin.backups.schedule.queryOptions()), + ); + } + await Promise.all(promises); + }, + pendingComponent: SettingsSkeleton, component: SettingsPage, }); +function SettingsSkeleton() { + return ( +
+
+
+ + +
+ +
+ + + + +
+ ); +} + function SettingsPage() { const { session } = Route.useRouteContext(); const isAdmin = session.user.role === "admin"; diff --git a/bun.lock b/bun.lock index d655e02..bbce0d5 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,6 @@ "clsx": "2.1.1", "cmdk": "1.1.1", "jotai": "2.18.1", - "media-chrome": "4.18.1", "motion": "12.38.0", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts index 733e495..9d4fc47 100644 --- a/packages/core/src/backup.ts +++ b/packages/core/src/backup.ts @@ -4,9 +4,8 @@ import { mkdir, readdir } from "node:fs/promises"; import path from "node:path"; import { BACKUP_DIR, DATABASE_URL } from "@sofa/config"; -import { closeDatabase } from "@sofa/db/client"; +import { closeDatabase, vacuumDatabase } from "@sofa/db/client"; import { runMigrations } from "@sofa/db/migrate"; -import { vacuumInto } from "@sofa/db/utils"; import { createLogger } from "@sofa/logger"; function formatTimestamp(date: Date): string { @@ -153,7 +152,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise { const filename = `${prefix}-${timestamp}.db`; const dest = path.join(BACKUP_DIR, filename); - vacuumInto(dest); + vacuumDatabase(dest); const s = await Bun.file(dest).stat(); log.info(`Created backup: ${filename} (${s.size} bytes)`); diff --git a/packages/db/package.json b/packages/db/package.json index 120e508..4496373 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -8,7 +8,6 @@ "./migrate": "./src/migrate.ts", "./schema": "./src/schema.ts", "./test-utils": "./src/test-utils.ts", - "./utils": "./src/utils.ts", "./queries/*": "./src/queries/*.ts" }, "scripts": { diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 7d67df6..5ea9c42 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -38,6 +38,10 @@ function getClient() { globalForDb._client.run("PRAGMA journal_mode = WAL"); globalForDb._client.run("PRAGMA foreign_keys = ON"); globalForDb._client.run("PRAGMA busy_timeout = 5000"); + globalForDb._client.run("PRAGMA synchronous = NORMAL"); + globalForDb._client.run("PRAGMA cache_size = -64000"); + globalForDb._client.run("PRAGMA temp_store = MEMORY"); + globalForDb._client.run("PRAGMA mmap_size = 268435456"); } return globalForDb._client; } @@ -59,6 +63,15 @@ export const db = new Proxy({} as ReturnType, { }, }); +/** Run PRAGMA optimize to refresh query planner statistics. */ +export function optimizeDatabase() { + getClient().run("PRAGMA optimize"); +} + +export function vacuumDatabase(into: string): void { + getClient().run("VACUUM INTO ?", [into.replace(/'/g, "''")]); +} + /** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */ export function closeDatabase() { globalForDb._client?.close(); diff --git a/packages/db/src/queries/discovery.ts b/packages/db/src/queries/discovery.ts index cc9cee5..f11e2a3 100644 --- a/packages/db/src/queries/discovery.ts +++ b/packages/db/src/queries/discovery.ts @@ -170,14 +170,7 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) { eq(userTitleStatus.userId, userId), ); - const [{ count }] = db - .select({ count: sql`count(*)` }) - .from(titles) - .innerJoin(userTitleStatus, joinCondition) - .where(availabilityFilter) - .all(); - - const items = db + const rows = db .select({ titleId: titles.id, title: titles.title, @@ -190,6 +183,7 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) { voteAverage: titles.voteAverage, popularity: titles.popularity, userStatus: userTitleStatus.status, + totalCount: sql`count(*) over()`.as("totalCount"), }) .from(titles) .innerJoin(userTitleStatus, joinCondition) @@ -199,7 +193,17 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) { .offset(offset) .all(); - const totalResults = count ?? 0; + let totalResults = rows[0]?.totalCount ?? 0; + if (rows.length === 0 && offset > 0) { + const [{ count }] = db + .select({ count: sql`count(*)` }) + .from(titles) + .innerJoin(userTitleStatus, joinCondition) + .where(availabilityFilter) + .all(); + totalResults = count ?? 0; + } + const items = rows.map(({ totalCount: _, ...item }) => item); return { items, page, diff --git a/packages/db/src/queries/tracking.ts b/packages/db/src/queries/tracking.ts index a590cdd..ae81605 100644 --- a/packages/db/src/queries/tracking.ts +++ b/packages/db/src/queries/tracking.ts @@ -275,49 +275,34 @@ export function getEpisodeProgressByTitleIds(userId: string, titleIds: string[]) } export function getUserTitleInfo(userId: string, titleId: string) { - const status = db - .select() + const info = db + .select({ + status: userTitleStatus.status, + ratingStars: userRatings.ratingStars, + }) .from(userTitleStatus) + .leftJoin( + userRatings, + and( + eq(userRatings.userId, userTitleStatus.userId), + eq(userRatings.titleId, userTitleStatus.titleId), + ), + ) .where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId))) .get(); - const rating = db - .select() - .from(userRatings) - .where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId))) - .get(); - - const allEps = db - .select({ id: episodes.id }) - .from(episodes) + const watchedEpisodeIds = db + .selectDistinct({ episodeId: userEpisodeWatches.episodeId }) + .from(userEpisodeWatches) + .innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id)) .innerJoin(seasons, eq(episodes.seasonId, seasons.id)) - .where(eq(seasons.titleId, titleId)) - .all(); - - const epIds = allEps.map((ep) => ep.id); - - const watchedEpisodeIds = - epIds.length > 0 - ? Array.from( - new Set( - db - .select({ episodeId: userEpisodeWatches.episodeId }) - .from(userEpisodeWatches) - .where( - and( - eq(userEpisodeWatches.userId, userId), - inArray(userEpisodeWatches.episodeId, epIds), - ), - ) - .all() - .map((w) => w.episodeId), - ), - ) - : []; + .where(and(eq(userEpisodeWatches.userId, userId), eq(seasons.titleId, titleId))) + .all() + .map((w) => w.episodeId); return { - status: status?.status ?? null, - rating: rating?.ratingStars ?? null, + status: info?.status ?? null, + rating: info?.ratingStars ?? null, episodeWatches: watchedEpisodeIds, }; } diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts deleted file mode 100644 index b7f938d..0000000 --- a/packages/db/src/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { sql } from "drizzle-orm"; - -import { db } from "./client"; - -export function vacuumInto(destPath: string): void { - db.run(sql.raw(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`)); -}