mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): add dashboard with stats, challenges, and streak widgets
This commit is contained in:
Executable
+203
@@ -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."
|
||||
@@ -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;
|
||||
\$\$;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user