From 19aeb9040324cf46d060d5a3c39f8d65a0c773fd Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 3 Apr 2026 20:15:58 +0500 Subject: [PATCH] perf: cache home stats + ratings, fix discover card pop animation Cache home stats (10 min) and recently added books (5 min) to avoid hitting PocketBase on every homepage load. Cache all ratings for discovery ranking (5 min) with invalidation on setBookRating. invalidateBooksCache now clears all related keys atomically. Fix discover card pop-to-full-size bug: new card now transitions from scale(0.95) to scale(1.0) matching its back-card position, instead of snapping to full size instantly after each swipe. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/server/pocketbase.ts | 40 ++++++++++++++++++++++++--- ui/src/routes/discover/+page.svelte | 42 +++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index d3594a9..1c26f63 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -211,6 +211,20 @@ async function listOne(collection: string, filter: string, sort = ''): Promis const BOOKS_CACHE_KEY = 'books:all'; const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes +const RATINGS_CACHE_KEY = 'book_ratings:all'; +const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes + +const HOME_STATS_CACHE_KEY = 'home:stats'; +const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact + +async function getAllRatings(): Promise { + const cached = await cache.get(RATINGS_CACHE_KEY); + if (cached) return cached; + const ratings = await listAll('book_ratings', '').catch(() => [] as BookRating[]); + await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL); + return ratings; +} + export async function listBooks(): Promise { const cached = await cache.get(BOOKS_CACHE_KEY); if (cached) { @@ -282,7 +296,11 @@ export async function getBooksBySlugs(slugs: Iterable): Promise /** Invalidate the books cache (call after a book is created/updated/deleted). */ export async function invalidateBooksCache(): Promise { - await cache.invalidate(BOOKS_CACHE_KEY); + await Promise.all([ + cache.invalidate(BOOKS_CACHE_KEY), + cache.invalidate(HOME_STATS_CACHE_KEY), + cache.invalidatePattern('books:recent:*') + ]); } export async function getBook(slug: string): Promise { @@ -290,7 +308,12 @@ export async function getBook(slug: string): Promise { } export async function recentlyAddedBooks(limit = 6): Promise { - return listN('books', limit, '', '-meta_updated'); + const key = `books:recent:${limit}`; + const cached = await cache.get(key); + if (cached) return cached; + const books = await listN('books', limit, '', '-meta_updated'); + await cache.set(key, books, 5 * 60); // 5 minutes + return books; } export interface HomeStats { @@ -299,11 +322,19 @@ export interface HomeStats { } export async function getHomeStats(): Promise { + const cached = await cache.get(HOME_STATS_CACHE_KEY); + if (cached) return cached; const [totalBooks, totalChapters] = await Promise.all([ countCollection('books'), countCollection('chapters_idx') ]); - return { totalBooks, totalChapters }; + const stats = { totalBooks, totalChapters }; + await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL); + return stats; +} + +export async function invalidateHomeStatsCache(): Promise { + await cache.invalidate(HOME_STATS_CACHE_KEY); } // ─── Chapter index ──────────────────────────────────────────────────────────── @@ -1849,6 +1880,7 @@ export async function setBookRating( } else { await pbPost('/api/collections/book_ratings/records', payload); } + await cache.invalidate(RATINGS_CACHE_KEY); } // ─── Shelves ─────────────────────────────────────────────────────────────────── @@ -1918,7 +1950,7 @@ export async function getBooksForDiscovery( // Fetch avg ratings for candidates, weight top-rated books to surface earlier. // Fetch in one shot for all candidate slugs. Low-rated / unrated books still // appear — they're just pushed further back via a stable sort before shuffle. - const ratingRows = await listAll('book_ratings', '').catch(() => [] as BookRating[]); + const ratingRows = await getAllRatings(); const ratingMap = new Map(); for (const r of ratingRows) { const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 }; diff --git a/ui/src/routes/discover/+page.svelte b/ui/src/routes/discover/+page.svelte index 7bc1e53..57d5847 100644 --- a/ui/src/routes/discover/+page.svelte +++ b/ui/src/routes/discover/+page.svelte @@ -130,7 +130,43 @@ let cardEl = $state(null); + // ── Card entry animation (prevents pop-to-full-size after swipe) ───────────── + let cardEntering = $state(false); + let entryTransition = $state(false); + let entryCleanup: ReturnType | null = null; + + function startEntryAnimation() { + if (entryCleanup) clearTimeout(entryCleanup); + cardEntering = true; + entryTransition = true; + requestAnimationFrame(() => { + cardEntering = false; + entryCleanup = setTimeout(() => { entryTransition = false; }, 400); + }); + } + + function cancelEntryAnimation() { + if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; } + cardEntering = false; + entryTransition = false; + } + + const activeTransform = $derived( + cardEntering + ? 'scale(0.95) translateY(13px)' + : `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)` + ); + + const activeTransition = $derived( + isDragging + ? 'none' + : (transitioning || entryTransition) + ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' + : 'none' + ); + function onPointerDown(e: PointerEvent) { + cancelEntryAnimation(); if (animating || !currentBook) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); startX = e.clientX; @@ -216,6 +252,8 @@ if (action === 'read_now') { goto(`/books/${book.slug}`); + } else { + startEntryAnimation(); } } @@ -493,8 +531,8 @@ bind:this={cardEl} class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10" style=" - transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg); - transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'}; + transform: {activeTransform}; + transition: {activeTransition}; touch-action: none; " onpointerdown={onPointerDown}