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 <noreply@anthropic.com>
This commit is contained in:
@@ -331,10 +331,46 @@ export async function invalidateBooksCache(): Promise<void> {
|
||||
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<Book[]> {
|
||||
const key = `books:trending:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('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<string>,
|
||||
limit = 8
|
||||
): Promise<Book[]> {
|
||||
if (topGenres.length === 0) return [];
|
||||
const sortedGenres = [...topGenres].sort();
|
||||
const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
|
||||
let books = await cache.get<Book[]>(key);
|
||||
if (!books) {
|
||||
const genreFilter = sortedGenres
|
||||
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
|
||||
.join('||');
|
||||
books = await listN<Book>('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<Book | null> {
|
||||
return listOne<Book>('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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user