diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts
index 38fbd96..9e563c8 100644
--- a/ui/src/lib/audio.svelte.ts
+++ b/ui/src/lib/audio.svelte.ts
@@ -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
diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte
index b1361b3..2fb465a 100644
--- a/ui/src/lib/components/AudioPlayer.svelte
+++ b/ui/src/lib/components/AudioPlayer.svelte
@@ -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 @@
+
+
+
+
+
+
+ {#if activeTab === 'discover'}
{#if deckEmpty}
@@ -606,4 +641,58 @@
Swipe or tap buttons · Tap card for details
{/if}
+ {/if}
+
+ {#if activeTab === 'history'}
+
+ {#if !votedBooks.length}
+
No votes yet — start swiping!
+ {:else}
+ {#each votedBooks as v (v.slug)}
+ {@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
+ {@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
+
+
+ {#if v.book?.cover}
+

+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts
index c4ccea7..066f288 100644
--- a/ui/src/routes/profile/+page.server.ts
+++ b/ui/src/routes/profile/+page.server.ts
@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
-import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
+import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ locals }) => {
let sessions: Awaited> = [];
let email: string | null = null;
let polarCustomerId: string | null = null;
+ let stats: Awaited> | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
@@ -25,9 +26,12 @@ export const load: PageServerLoad = async ({ locals }) => {
}
try {
- sessions = await listUserSessions(locals.user.id);
+ [sessions, stats] = await Promise.all([
+ listUserSessions(locals.user.id),
+ getUserStats(locals.sessionId, locals.user.id)
+ ]);
} catch (e) {
- log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
+ log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
return {
@@ -35,6 +39,11 @@ export const load: PageServerLoad = async ({ locals }) => {
avatarUrl,
email,
polarCustomerId,
+ stats: stats ?? {
+ totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
+ booksPlanToRead: 0, booksDropped: 0, topGenres: [],
+ avgRatingGiven: 0, streak: 0
+ },
sessions: sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,
diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte
index 1cc7f8c..c6daa5b 100644
--- a/ui/src/routes/profile/+page.svelte
+++ b/ui/src/routes/profile/+page.svelte
@@ -184,6 +184,9 @@
}, 800) as unknown as number;
});
+ // ── Tab ──────────────────────────────────────────────────────────────────────
+ let activeTab = $state<'profile' | 'stats'>('profile');
+
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = {
id: string;
@@ -317,6 +320,23 @@
+
+
+ {#each (['profile', 'stats'] as const) as tab}
+
+ {/each}
+
+
+ {#if activeTab === 'profile'}
{#if !data.isPro}
@@ -565,5 +585,77 @@
{/if}
+ {/if}
+
+ {#if activeTab === 'stats'}
+
+
+
+
+ Reading Overview
+
+ {#each [
+ { label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
+ { label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
+ { label: 'Reading', value: data.stats.booksReading, icon: '📚' },
+ { label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
+ ] as stat}
+
+
{stat.value}
+
{stat.label}
+
+ {/each}
+
+
+
+
+
+ Activity
+
+
+
🔥
+
+
{data.stats.streak}
+
day streak
+
+
+
+
⭐
+
+
+ {data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
+
+
avg rating given
+
+
+
+
+
+
+ {#if data.stats.topGenres.length > 0}
+
+ Favourite Genres
+
+ {#each data.stats.topGenres as genre, i}
+
+ {#if i === 0}🏆{/if}
+ {genre}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if data.stats.booksDropped > 0}
+
+ {data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
+ revisit your library
+
+ {/if}
+
+
+ {/if}