Compare commits

...

1 Commits

Author SHA1 Message Date
Admin
19aeb90403 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>
2026-04-03 20:15:58 +05:00
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_KEY = 'books:all';
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes 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[]> { export async function listBooks(): Promise<Book[]> {
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY); const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (cached) { 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). */ /** Invalidate the books cache (call after a book is created/updated/deleted). */
export async function invalidateBooksCache(): Promise<void> { 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> { 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[]> { 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 { export interface HomeStats {
@@ -299,11 +322,19 @@ export interface HomeStats {
} }
export async function getHomeStats(): Promise<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([ const [totalBooks, totalChapters] = await Promise.all([
countCollection('books'), countCollection('books'),
countCollection('chapters_idx') 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 ──────────────────────────────────────────────────────────── // ─── Chapter index ────────────────────────────────────────────────────────────
@@ -1849,6 +1880,7 @@ export async function setBookRating(
} else { } else {
await pbPost('/api/collections/book_ratings/records', payload); await pbPost('/api/collections/book_ratings/records', payload);
} }
await cache.invalidate(RATINGS_CACHE_KEY);
} }
// ─── Shelves ─────────────────────────────────────────────────────────────────── // ─── Shelves ───────────────────────────────────────────────────────────────────
@@ -1918,7 +1950,7 @@ export async function getBooksForDiscovery(
// Fetch avg ratings for candidates, weight top-rated books to surface earlier. // 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 // 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. // 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 }>(); const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) { for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 }; const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };

View File

@@ -130,7 +130,43 @@
let cardEl = $state<HTMLDivElement | null>(null); 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) { function onPointerDown(e: PointerEvent) {
cancelEntryAnimation();
if (animating || !currentBook) return; if (animating || !currentBook) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startX = e.clientX; startX = e.clientX;
@@ -216,6 +252,8 @@
if (action === 'read_now') { if (action === 'read_now') {
goto(`/books/${book.slug}`); goto(`/books/${book.slug}`);
} else {
startEntryAnimation();
} }
} }
@@ -493,8 +531,8 @@
bind:this={cardEl} bind:this={cardEl}
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10" class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style=" style="
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg); transform: {activeTransform};
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'}; transition: {activeTransition};
touch-action: none; touch-action: none;
" "
onpointerdown={onPointerDown} onpointerdown={onPointerDown}