From a0404cea572bc38cad46b555c4bb0ab7a6d61af5 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 5 Apr 2026 23:08:36 +0500 Subject: [PATCH] feat(home): add streak widget, trending, genre recs, completed shelf, audio quick-play - Reading streak + books-in-progress mini-widget (derived from progress timestamps) - "N chapters left" badge on continue-reading shelf cards - Audio listen button on hero card and hover-overlay on shelf cards (autoStartChapter + goto) - Completed shelf section for books where chapter >= total_chapters - Trending Now section (books sorted by ranking field, 15-min cache) - "Because you read [Genre]" recommendations (genre-matched, excludes user's books, 10-min cache) - Both new sections are hideable via the existing show/hide mechanism - getTrendingBooks / getRecommendedBooks added to pocketbase.ts - Cache invalidation for trending/recs added to invalidateBooksCache Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/server/pocketbase.ts | 53 ++++++- ui/src/routes/+page.server.ts | 91 +++++++++--- ui/src/routes/+page.svelte | 251 ++++++++++++++++++++++++++++---- 3 files changed, 339 insertions(+), 56 deletions(-) diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index f6f8bae..3a41c82 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -331,10 +331,46 @@ export async function invalidateBooksCache(): Promise { cache.invalidate(BOOKS_CACHE_KEY), cache.invalidate(HOME_STATS_CACHE_KEY), cache.invalidatePattern('books:recent:*'), - cache.invalidatePattern('books:recently-updated:*') + cache.invalidatePattern('books:recently-updated:*'), + cache.invalidatePattern('books:trending:*'), + cache.invalidatePattern('books:recs:*') ]); } +/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */ +export async function getTrendingBooks(limit = 8): Promise { + const key = `books:trending:${limit}`; + const cached = await cache.get(key); + if (cached) return cached; + const books = await listN('books', limit, 'ranking>0', '+ranking'); + await cache.set(key, books, 15 * 60); + return books; +} + +/** + * Books matching the given genres that the user hasn't read yet. + * The raw genre-query result is cached (shared across users); per-user slug + * exclusion is applied in memory afterwards. + */ +export async function getRecommendedBooks( + topGenres: string[], + excludeSlugs: Set, + limit = 8 +): Promise { + if (topGenres.length === 0) return []; + const sortedGenres = [...topGenres].sort(); + const key = `books:recs:${sortedGenres.join(':')}:${limit}`; + let books = await cache.get(key); + if (!books) { + const genreFilter = sortedGenres + .map((g) => `genres~"${g.replace(/"/g, '')}"`) + .join('||'); + books = await listN('books', limit * 4, genreFilter, '+ranking'); + await cache.set(key, books, 10 * 60); + } + return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit); +} + export async function getBook(slug: string): Promise { return listOne('books', `slug="${slug}"`); } @@ -1122,12 +1158,14 @@ export interface UserSession { } /** - * Generate a short device fingerprint from user-agent + IP. - * SHA-256 of the concatenation, first 16 hex chars. + * Generate a short device fingerprint from the user-agent alone. + * IP is intentionally excluded so that network changes (VPN, mobile data, + * home vs. office wifi) don't create duplicate sessions for the same device. + * SHA-256 of the user-agent string, first 16 hex chars. */ -function deviceFingerprint(userAgent: string, ip: string): string { +function deviceFingerprint(userAgent: string, _ip?: string): string { return createHash('sha256') - .update(`${userAgent}::${ip}`) + .update(userAgent) .digest('hex') .slice(0, 16); } @@ -1154,12 +1192,13 @@ export async function upsertUserSession( ); if (existing) { - // Touch last_seen and return the existing authSessionId + // Touch last_seen and update IP (may have changed due to network switch). + // Return the existing authSessionId so no new session row is created. const token = await getToken(); await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ last_seen: new Date().toISOString() }) + body: JSON.stringify({ last_seen: new Date().toISOString(), ip }) }).catch(() => {}); return { authSessionId: existing.session_id, recordId: existing.id }; } diff --git a/ui/src/routes/+page.server.ts b/ui/src/routes/+page.server.ts index f680382..102a702 100644 --- a/ui/src/routes/+page.server.ts +++ b/ui/src/routes/+page.server.ts @@ -4,15 +4,35 @@ import { recentlyUpdatedBooks, allProgress, getHomeStats, - getSubscriptionFeed + getSubscriptionFeed, + getTrendingBooks, + getRecommendedBooks } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import type { Book, Progress } from '$lib/server/pocketbase'; +function parseGenresLocal(genres: string[] | string | null | undefined): string[] { + if (!genres) return []; + if (Array.isArray(genres)) return genres; + try { return JSON.parse(genres) as string[]; } catch { return []; } +} + +function computeStreak(progressList: Progress[]): number { + const days = new Set( + progressList.filter((p) => p.updated).map((p) => p.updated.slice(0, 10)) + ); + let streak = 0; + const today = new Date(); + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setUTCDate(d.getUTCDate() - i); + if (days.has(d.toISOString().slice(0, 10))) streak++; + else if (i > 0) break; + } + return streak; +} + export const load: PageServerLoad = async ({ locals }) => { - // Step 1: fetch progress + recent books + stats in parallel. - // We intentionally do NOT call listBooks() here — we only need books that - // appear in the user's progress list, which is a tiny subset of 15k books. let recentBooks: Book[] = []; let progressList: Progress[] = []; let stats = { totalBooks: 0, totalChapters: 0 }; @@ -27,8 +47,9 @@ export const load: PageServerLoad = async ({ locals }) => { log.error('home', 'failed to load home data', { err: String(e) }); } - // Step 2: fetch only the books we actually need for continue-reading. - // This is O(progress entries) instead of O(15k books). + const streak = computeStreak(progressList); + + // Fetch only the books we need for continue-reading (avoid loading all books) const progressSlugs = progressList.map((p) => p.slug); const progressBooks = progressSlugs.length > 0 ? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[]) @@ -36,31 +57,65 @@ export const load: PageServerLoad = async ({ locals }) => { const bookMap = new Map(progressBooks.map((b) => [b.slug, b])); - // Continue reading: progress entries joined with book data, most recent first + // All continue-reading entries joined with book data, most recent first const continueReading = progressList .filter((p) => bookMap.has(p.slug)) - .slice(0, 6) + .slice(0, 8) .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); - // Recently updated: deduplicate against continueReading slugs + // Split into in-progress vs completed + const continueInProgress = continueReading.filter( + ({ book, chapter }) => book.total_chapters === 0 || chapter < book.total_chapters + ); + const continueCompleted = continueReading.filter( + ({ book, chapter }) => book.total_chapters > 0 && chapter >= book.total_chapters + ); + + // Top genres from books the user has been reading + const genreFreq = new Map(); + for (const { book } of continueReading) { + for (const g of parseGenresLocal(book.genres)) { + genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1); + } + } + const topGenres = [...genreFreq.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([g]) => g); + + // Deduplicate recently-updated against in-progress slugs const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); - // Subscription feed — only when logged in - const subscriptionFeed = locals.user - ? await getSubscriptionFeed(locals.user.id, 12).catch((e) => { - log.error('home', 'failed to load subscription feed', { err: String(e) }); - return [] as Awaited>; - }) - : []; + // Fetch trending, recommendations, and subscription feed in parallel + const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([ + getTrendingBooks(8).catch(() => [] as Book[]), + topGenres.length > 0 + ? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[]) + : Promise.resolve([] as Book[]), + locals.user + ? getSubscriptionFeed(locals.user.id, 12).catch((e) => { + log.error('home', 'failed to load subscription feed', { err: String(e) }); + return [] as Awaited>; + }) + : Promise.resolve([]) + ]); + + // Strip books the user is already reading from trending (redundant) + const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug)); return { - continueReading, + continueInProgress, + continueCompleted, recentlyUpdated, subscriptionFeed, + trendingBooks: trendingFiltered, + recommendedBooks, + topGenre: topGenres[0] ?? null, stats: { ...stats, - booksInProgress: continueReading.length + booksInProgress: continueInProgress.length, + streak } }; }; diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index e75be98..67c7ee7 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -1,20 +1,16 @@ @@ -81,13 +88,11 @@ {#if heroBook} -
- +
+ +
@@ -113,20 +118,54 @@ {/if}
- + {m.home_chapter_badge({ n: String(heroBook.chapter) })} - + + + {#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters} + {@const ahead = heroBook.book.total_chapters - heroBook.chapter} + + {/if} {#each parseGenres(heroBook.book.genres).slice(0, 2) as genre} {genre} {/each}
- +
{/if} - + +{#if streak > 0} +
+ + + + + {streak} + day{streak !== 1 ? 's' : ''} reading + + {#if data.stats.booksInProgress > 0} + + {data.stats.booksInProgress} {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress + + {/if} +
+{/if} + + {#if shelfBooks.length > 0}
@@ -135,7 +174,56 @@
{#each shelfBooks as { book, chapter }} - + +
+ {#if book.cover} + {book.title} + {:else} +
+ +
+ {/if} + + + {m.home_chapter_badge({ n: String(chapter) })} + + + {#if book.total_chapters > 0 && chapter < book.total_chapters} + + {book.total_chapters - chapter} left + + {/if} +
+
+ + + +

{book.title ?? ''}

+
+
+ {/each} + +
+{/if} + + +{#if data.continueCompleted.length > 0} +
+
+

Completed

+
+

{book.title ?? ''}

+ {#if book.total_chapters > 0} +

{chapter} chapters

+ {/if}
{/each} @@ -184,6 +273,102 @@
{/if} + +{#if data.trendingBooks.length > 0 && !hidden.has('trending')} +
+
+

Trending Now

+
+ {m.home_view_all()} + +
+
+ +
+{/if} + + +{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')} +
+
+

+ Because you read {data.topGenre} +

+ +
+ +
+{/if} + {#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
@@ -272,8 +457,8 @@
{/if} - -{#if data.continueReading.length === 0 && dedupedRecent.length === 0} + +{#if data.continueInProgress.length === 0 && data.continueCompleted.length === 0 && dedupedRecent.length === 0}

{m.home_empty_title()}

{m.home_empty_body()}

@@ -301,8 +486,12 @@ {/if} -
+
{data.stats.totalBooks.toLocaleString()} {m.home_stat_books()} {data.stats.totalChapters.toLocaleString()} {m.home_stat_chapters()} + {#if streak > 0} + + {streak} day streak 🔥 + {/if}