feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s

**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq

**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts

**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic

**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-03 07:26:54 +05:00
parent 73a92ccf8f
commit 06d4a7bfd4
9 changed files with 399 additions and 22 deletions

View File

@@ -79,6 +79,9 @@ class AudioStore {
/** Epoch ms when sleep timer should fire. 0 = off. */
sleepUntil = $state(0);
/** When true, pause after the current chapter ends instead of navigating. */
sleepAfterChapter = $state(false);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends

View File

@@ -699,16 +699,22 @@
});
function cycleSleepTimer() {
if (!audioStore.sleepUntil) {
// Start at first option (15 min)
// Currently: no timer active at all
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
return;
}
// Currently: end-of-chapter mode — move to 15m
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
return;
}
// Currently: timed mode — cycle to next or turn off
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex(m => m >= currentMin);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
// Was at max or past last — turn off
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
@@ -933,14 +939,20 @@
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepUntil}
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep

View File

@@ -1915,11 +1915,167 @@ export async function getBooksForDiscovery(
if (sf.length >= 3) candidates = sf;
}
// Fisher-Yates shuffle
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
// 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 ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
cur.sum += r.rating;
cur.count += 1;
ratingMap.set(r.slug, cur);
}
const avgRating = (slug: string) => {
const e = ratingMap.get(slug);
return e && e.count > 0 ? e.sum / e.count : 0;
};
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 010
let start = 0;
while (start < candidates.length) {
let end = start + 1;
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
for (let i = end - 1; i > start; i--) {
const j = start + Math.floor(Math.random() * (i - start + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
start = end;
}
return candidates.slice(0, 50);
}
// ─── Discovery history ─────────────────────────────────────────────────────────
export interface VotedBook {
slug: string;
action: DiscoveryVote['action'];
votedAt: string;
book?: Book;
}
export async function getVotedBooks(
sessionId: string,
userId?: string
): Promise<VotedBook[]> {
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
'discovery_votes',
discoveryFilter(sessionId, userId),
'-created'
).catch(() => []);
if (!votes.length) return [];
const slugs = [...new Set(votes.map((v) => v.slug))];
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
const bookMap = new Map(books.map((b) => [b.slug, b]));
return votes.map((v) => ({
slug: v.slug,
action: v.action,
votedAt: v.created,
book: bookMap.get(v.slug)
}));
}
export async function undoDiscoveryVote(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
}
// ─── User stats ────────────────────────────────────────────────────────────────
export interface UserStats {
totalChaptersRead: number;
booksReading: number;
booksCompleted: number;
booksPlanToRead: number;
booksDropped: number;
topGenres: string[]; // top 3 by frequency
avgRatingGiven: number; // 0 if no ratings
streak: number; // consecutive days with progress
}
export async function getUserStats(
sessionId: string,
userId?: string
): Promise<UserStats> {
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
listAll<BookRating>('book_ratings', filter).catch(() => []),
listBooks().catch(() => [] as Book[])
]);
// shelf counts
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
for (const r of libraryRows) {
const s = r.shelf || 'reading';
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
}
// top genres from books in progress/library
const libSlugs = new Set(libraryRows.map((r) => r.slug));
const progSlugs = new Set(progressRows.map((r) => r.slug));
const allSlugs = new Set([...libSlugs, ...progSlugs]);
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
const genreFreq = new Map<string, number>();
for (const slug of allSlugs) {
const book = bookMap.get(slug);
if (!book) continue;
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);
// avg rating given
const avgRatingGiven =
ratingRows.length > 0
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
: 0;
// reading streak: count consecutive calendar days (UTC) with a progress update
const days = new Set(
progressRows
.filter((r) => r.updated)
.map((r) => r.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; // gap — stop
}
return {
totalChaptersRead: progressRows.length,
booksReading: shelfCounts.reading,
booksCompleted: shelfCounts.completed,
booksPlanToRead: shelfCounts.plan_to_read,
booksDropped: shelfCounts.dropped,
topGenres,
avgRatingGiven,
streak
};
}