perf: cache home stats + ratings, fix discover card pop animation
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 48s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / backend (push) Successful in 2m52s
Release / Docker / ui (push) Successful in 2m4s
Release / Gitea Release (push) Successful in 21s

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 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-03 20:15:58 +05:00
parent 06d4a7bfd4
commit 19aeb90403
2 changed files with 76 additions and 6 deletions

View File

@@ -211,6 +211,20 @@ async function listOne<T>(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<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
return ratings;
}
export async function listBooks(): Promise<Book[]> {
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (cached) {
@@ -282,7 +296,11 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
/** Invalidate the books cache (call after a book is created/updated/deleted). */
export async function invalidateBooksCache(): Promise<void> {
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<Book | null> {
@@ -290,7 +308,12 @@ export async function getBook(slug: string): Promise<Book | null> {
}
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('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<HomeStats> {
const cached = await cache.get<HomeStats>(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<void> {
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<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
const ratingRows = await getAllRatings();
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };

View File

@@ -130,7 +130,43 @@
let cardEl = $state<HTMLDivElement | null>(null);
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
let cardEntering = $state(false);
let entryTransition = $state(false);
let entryCleanup: ReturnType<typeof setTimeout> | 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}