feat(stats): add dashboard with stats, challenges, and streak widgets

This commit is contained in:
neon
2026-04-22 15:55:14 -06:00
parent cbb814827e
commit 46208fc82d
46 changed files with 5074 additions and 153 deletions
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
# Seeds data for dashboard widgets for user_id=1.
# Covers: Highlight of the Day, Monthly Challenge, Year Projection,
# Neglected Gems, Reading DNA, The Long Wait,
# Diversity Score, Reading Rhythm.
#
# Safe to run multiple times - uses INSERT ... ON CONFLICT DO NOTHING / DO UPDATE.
# Usage: ./scripts/fixtures/seed-dashboard-widgets.sh [USER_ID]
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
POSTGRES_USER="${POSTGRES_USER:-bookorbit}"
POSTGRES_DB="${POSTGRES_DB:-bookorbit}"
TARGET_USER_ID="${1:-1}"
echo "Seeding dashboard widget data for user_id=${TARGET_USER_ID}..."
docker compose -f docker-compose.dev.yml exec -T postgres psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
DO \$\$
DECLARE
v_uid int := ${TARGET_USER_ID};
v_book_id int;
v_lib_id int;
v_day date;
v_secs int;
i int;
BEGIN
-- ─── Pick a library (superuser sees all libraries) ────────────────────────
SELECT id INTO v_lib_id FROM libraries ORDER BY id LIMIT 1;
IF v_lib_id IS NULL THEN
RAISE EXCEPTION 'No libraries found in the database. Create a library first.';
END IF;
RAISE NOTICE 'Using library_id=%', v_lib_id;
-- ─── WIDGET 1: Highlight of the Day ───────────────────────────────────────
-- Need 15+ annotations with real-looking highlight text.
-- Pick the first book that belongs to this user's library.
SELECT b.id INTO v_book_id
FROM books b
WHERE b.library_id = v_lib_id AND b.status = 'present'
LIMIT 1;
IF v_book_id IS NOT NULL THEN
INSERT INTO annotations (user_id, book_id, cfi, text, style, chapter_title, created_at, updated_at)
VALUES
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/2)', 'The man who does not read has no advantage over the man who cannot read.', 'highlight', 'Preface', now() - interval '10 days', now() - interval '10 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/4)', 'Not all those who wander are lost; the roots of some are deeper than most.', 'highlight', 'Chapter 1', now() - interval '9 days', now() - interval '9 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/6)', 'It is our choices that show what we truly are, far more than our abilities.', 'highlight', 'Chapter 2', now() - interval '8 days', now() - interval '8 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/8)', 'In the middle of every difficulty lies opportunity. The curious mind finds it first.', 'highlight', 'Chapter 3', now() - interval '7 days', now() - interval '7 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/10)', 'Words are, in my not-so-humble opinion, our most inexhaustible source of magic.', 'highlight', 'Chapter 4', now() - interval '6 days', now() - interval '6 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/12)', 'A reader lives a thousand lives before he dies. The man who never reads lives only one.', 'highlight', 'Chapter 5', now() - interval '5 days', now() - interval '5 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/14)', 'The more that you read, the more things you will know. The more that you learn, the more places you will go.', 'highlight', 'Chapter 6', now() - interval '4 days', now() - interval '4 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/16)', 'Until I feared I would lose it, I never loved to read. One does not love breathing.', 'highlight', 'Chapter 7', now() - interval '3 days', now() - interval '3 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/18)', 'I declare after all there is no enjoyment like reading! How much sooner one tires of any thing than of a book!', 'highlight', 'Chapter 8', now() - interval '2 days', now() - interval '2 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/20)', 'There is no friend as loyal as a book. It waits for you without judgment, always open.', 'highlight', 'Chapter 9', now() - interval '1 day', now() - interval '1 day'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/22)', 'Reading gives us someplace to go when we have to stay where we are.', 'highlight', 'Chapter 10', now(), now()),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/24)', 'One must always be careful of books, and what is inside them, for words have the power to change us.', 'highlight', 'Chapter 11', now() - interval '12 days', now() - interval '12 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/26)', 'The world was hers for the reading.', 'highlight', 'Chapter 12', now() - interval '11 days', now() - interval '11 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/28)', 'Books are mirrors: you only see in them what you already have inside you.', 'underline', 'Chapter 13', now() - interval '13 days', now() - interval '13 days'),
(v_uid, v_book_id, 'epubcfi(/6/4!/4/2/30)', 'I took a deep breath and listened to the old brag of my heart: I am, I am, I am.', 'highlight', 'Chapter 14', now() - interval '14 days', now() - interval '14 days')
ON CONFLICT DO NOTHING;
RAISE NOTICE 'Annotations: inserted up to 15 highlights for book_id=%', v_book_id;
END IF;
-- ─── WIDGET 3: Monthly Challenge ───────────────────────────────────────────
-- Need user_reading_daily_stats with a recent streak + books finished this month.
-- Daily stats already seeded by seed-reading-sessions.sh; just ensure this month
-- has solid data (upsert so we don't lose existing stats).
FOR i IN 1..28 LOOP
v_day := date_trunc('month', current_date) + ((i - 1) * interval '1 day');
IF v_day <= current_date THEN
v_secs := CASE WHEN (i % 7 < 2) THEN 600 ELSE 2400 + (i * 120) END;
INSERT INTO user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
VALUES (v_uid, v_lib_id, v_day, v_secs, 0.02 + (i % 5) * 0.01, 2, now())
ON CONFLICT (user_id, library_id, day) DO UPDATE
SET reading_seconds = GREATEST(user_reading_daily_stats.reading_seconds, EXCLUDED.reading_seconds),
updated_at = EXCLUDED.updated_at;
END IF;
END LOOP;
RAISE NOTICE 'Monthly Challenge: upserted daily stats for current month';
-- ─── WIDGET 4: Year Projection ─────────────────────────────────────────────
-- Need books marked 'read' with finished_at spread across this year.
i := 0;
FOR v_book_id IN
SELECT b.id
FROM books b
LEFT JOIN user_book_status ubs ON ubs.book_id = b.id AND ubs.user_id = v_uid
WHERE b.library_id = v_lib_id AND b.status = 'present'
AND (ubs.status IS NULL OR ubs.status NOT IN ('read', 'skimmed', 'reading', 'rereading'))
LIMIT 18
LOOP
i := i + 1;
INSERT INTO user_book_status (user_id, book_id, status, started_at, finished_at, updated_at)
VALUES (
v_uid, v_book_id, 'read',
date_trunc('year', current_date) + ((i * 14 - 10) * interval '1 day'),
date_trunc('year', current_date) + (i * 14 * interval '1 day'),
now()
)
ON CONFLICT (user_id, book_id) DO UPDATE
SET status = 'read',
finished_at = COALESCE(user_book_status.finished_at,
date_trunc('year', current_date) + (i * 14 * interval '1 day')),
updated_at = EXCLUDED.updated_at;
END LOOP;
RAISE NOTICE 'Year Projection: marked % books as read this year', i;
-- ─── WIDGET 5: Neglected Gems ──────────────────────────────────────────────
-- Need books with bm.rating >= 8 that are NOT read by this user.
-- Set high ratings on some unread books and ensure they have no 'read' status.
i := 0;
FOR v_book_id IN
SELECT b.id
FROM books b
JOIN book_metadata bm ON bm.book_id = b.id
LEFT JOIN user_book_status ubs ON ubs.book_id = b.id AND ubs.user_id = v_uid
WHERE b.library_id = v_lib_id AND b.status = 'present'
AND (ubs.status IS NULL OR ubs.status NOT IN ('read', 'skimmed'))
LIMIT 10
LOOP
i := i + 1;
-- Give them a high rating (8, 9, or 10)
UPDATE book_metadata SET rating = 8 + (i % 3) WHERE book_id = v_book_id AND (rating IS NULL OR rating < 8);
-- Set as want_to_read so they appear as "neglected"
INSERT INTO user_book_status (user_id, book_id, status, started_at, updated_at)
VALUES (v_uid, v_book_id, 'want_to_read', now() - (i * 30 * interval '1 day'), now())
ON CONFLICT (user_id, book_id) DO NOTHING;
END LOOP;
RAISE NOTICE 'Neglected Gems: set high ratings + want_to_read on % books', i;
-- ─── WIDGET 6: Reading DNA ─────────────────────────────────────────────────
-- Needs: many books read (already done above), genres, authors.
-- Make sure genres are mapped. Check book_genres / book_authors are populated.
-- (They come from metadata import; we just ensure enough 'read' books exist.)
RAISE NOTICE 'Reading DNA: relies on book_genres/book_authors + read books (already seeded)';
-- ─── WIDGET 7: The Long Wait ───────────────────────────────────────────────
-- Need 1+ book with status = 'want_to_read' added a long time ago.
SELECT b.id INTO v_book_id
FROM books b
LEFT JOIN user_book_status ubs ON ubs.book_id = b.id AND ubs.user_id = v_uid
WHERE b.library_id = v_lib_id AND b.status = 'present'
AND ubs.book_id IS NULL
LIMIT 1;
IF v_book_id IS NOT NULL THEN
INSERT INTO user_book_status (user_id, book_id, status, started_at, updated_at)
VALUES (v_uid, v_book_id, 'want_to_read', now() - interval '730 days', now())
ON CONFLICT (user_id, book_id) DO UPDATE
SET status = 'want_to_read',
started_at = LEAST(user_book_status.started_at, EXCLUDED.started_at),
updated_at = EXCLUDED.updated_at
WHERE user_book_status.status NOT IN ('read', 'skimmed', 'reading', 'rereading');
RAISE NOTICE 'Long Wait: marked book_id=% as want_to_read 730 days ago', v_book_id;
END IF;
-- ─── WIDGET 8: Diversity Score ─────────────────────────────────────────────
-- Relies on read books having genres, authors, languages.
-- Ensure book_metadata has language populated for some read books.
UPDATE book_metadata bm
SET language = (ARRAY['en','fr','de','es','ja','pt'])[1 + (bm.book_id % 6)]
WHERE bm.book_id IN (
SELECT ubs.book_id FROM user_book_status ubs
WHERE ubs.user_id = v_uid AND ubs.status IN ('read','skimmed')
LIMIT 20
) AND bm.language IS NULL;
RAISE NOTICE 'Diversity Score: set languages on read books';
-- ─── WIDGET 9: Reading Rhythm ─────────────────────────────────────────────
-- Need user_reading_daily_stats for the last 28 days with realistic variance.
-- Use a mix of active and rest days.
FOR i IN 1..28 LOOP
v_day := current_date - ((28 - i) * interval '1 day');
v_secs := CASE
WHEN (i % 7 = 0) THEN 0 -- one rest day per week
WHEN (i % 7 = 6) THEN 4500 -- Saturday: long session
ELSE 1800 + (i % 5) * 600 -- weekdays: 30-80 min
END;
IF v_secs > 0 THEN
INSERT INTO user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
VALUES (v_uid, v_lib_id, v_day, v_secs, 0.01 + (i % 6) * 0.008, 1 + (i % 3), now())
ON CONFLICT (user_id, library_id, day) DO UPDATE
SET reading_seconds = GREATEST(user_reading_daily_stats.reading_seconds, EXCLUDED.reading_seconds),
sessions_count = GREATEST(user_reading_daily_stats.sessions_count, EXCLUDED.sessions_count),
updated_at = EXCLUDED.updated_at;
END IF;
END LOOP;
RAISE NOTICE 'Reading Rhythm: upserted 28-day daily stats';
RAISE NOTICE '=== Widget seed complete for user_id=% ===', v_uid;
END;
\$\$;
EOSQL
echo "Done."
+18 -5
View File
@@ -20,7 +20,7 @@ TARGET_USER_ID="${1:-1}"
echo "Seeding reading sessions for user_id=${TARGET_USER_ID}..."
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
docker compose -f docker-compose.dev.yml exec -T postgres psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
DO \$\$
DECLARE
@@ -41,6 +41,9 @@ DECLARE
v_file_ids int[];
v_total_sessions int := 0;
v_total_completions int := 0;
v_inserted_sessions int := 0;
v_inserted int;
v_is_completion boolean;
BEGIN
-- Verify user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
@@ -103,18 +106,25 @@ BEGIN
-- ~15% of sessions are completions (every 7th deterministically by loop)
IF (v_total_sessions % 7) = 0 THEN
v_end_pct := 100;
v_total_completions := v_total_completions + 1;
v_is_completion := true;
ELSE
v_end_pct := round((10 + random() * 80)::numeric, 2);
v_is_completion := false;
END IF;
v_session_id := md5(v_user_id::text || v_file_id::text || v_started::text || v_i::text);
v_total_sessions := v_total_sessions + 1;
INSERT INTO reading_sessions
(user_id, book_file_id, session_id, started_at, ended_at, duration_seconds, progress_delta, end_progress)
VALUES
(v_user_id, v_file_id, v_session_id, v_started, v_ended, v_duration, v_delta, v_end_pct)
ON CONFLICT (session_id) DO NOTHING;
ON CONFLICT (user_id, session_id) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
IF v_inserted = 0 THEN
CONTINUE;
END IF;
-- Real-time upsert of daily stats (mirrors what the server does on session save)
INSERT INTO user_reading_daily_stats
@@ -127,11 +137,14 @@ BEGIN
sessions_count = user_reading_daily_stats.sessions_count + 1,
updated_at = now();
v_total_sessions := v_total_sessions + 1;
v_inserted_sessions := v_inserted_sessions + 1;
IF v_is_completion THEN
v_total_completions := v_total_completions + 1;
END IF;
END LOOP;
END LOOP;
RAISE NOTICE 'Done: % sessions inserted (% completions)', v_total_sessions, v_total_completions;
RAISE NOTICE 'Done: % sessions generated, % inserted (% completions)', v_total_sessions, v_inserted_sessions, v_total_completions;
END;
\$\$;