tr]:last:border-b-0",
46 | className
47 | )}
48 | {...props}
49 | />
50 | )
51 | }
52 |
53 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
54 | return (
55 |
63 | )
64 | }
65 |
66 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
67 | return (
68 | [role=checkbox]]:translate-y-[2px]",
72 | className
73 | )}
74 | {...props}
75 | />
76 | )
77 | }
78 |
79 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
80 | return (
81 | | [role=checkbox]]:translate-y-[2px]",
85 | className
86 | )}
87 | {...props}
88 | />
89 | )
90 | }
91 |
92 | function TableCaption({
93 | className,
94 | ...props
95 | }: React.ComponentProps<"caption">) {
96 | return (
97 |
102 | )
103 | }
104 |
105 | export {
106 | Table,
107 | TableHeader,
108 | TableBody,
109 | TableFooter,
110 | TableHead,
111 | TableRow,
112 | TableCell,
113 | TableCaption,
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/dialogs/BookSummaryDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogDescription,
5 | DialogHeader,
6 | DialogTitle,
7 | } from "@/components/ui/dialog";
8 | import { useStatsStore } from "@/stores/useStatsStore";
9 | import Heatmap from "../Heatmap"; // your heatmap component
10 | import { formatMinutesToTime, today } from "@/lib/helpers/time";
11 | import { useRef } from "react";
12 | import { trimBookTitle } from "@/lib/helpers/epub";
13 |
14 | export default function BookSummaryDialog() {
15 | const { bookStatsSummary, closeBookStatsDialog } = useStatsStore(
16 | (store) => store
17 | );
18 |
19 | const containerRef = useRef(null);
20 |
21 | // Calculate total reading time from heatmap data
22 | const totalMinutes =
23 | bookStatsSummary.heatmap?.reduce((sum, day) => sum + day.minutesRead, 0) ||
24 | 0;
25 |
26 | // Map heatmap data for the heatmap component
27 | const heatmapData =
28 | bookStatsSummary.heatmap?.map((day) => ({
29 | date: day.day, // YYYY-MM-DD
30 | count: day.minutesRead, // count will be used for coloring
31 | })) || [];
32 |
33 | if (!bookStatsSummary.book) return null;
34 |
35 | return (
36 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { Link } from "react-router";
5 | import EpubReader from "@/components/EpubReader";
6 | import PdfReader from "@/components/PdfReader";
7 | import { useReaderStore } from "@/stores/useReaderStore";
8 | import { BookAlert, MoveRight, AlertTriangle } from "lucide-react";
9 | import { getFileExtension } from "@/lib/helpers/fs";
10 | import { OpenPdf } from "@/lib/types/pdf";
11 | import { useHistoryStore } from "@/stores/useHistoryStore";
12 |
13 | export function HomePage() {
14 | const { openBook, error, setOpenBook } = useReaderStore();
15 | const { history, loadHistory } = useHistoryStore();
16 |
17 | // Load last opened book from store on mount
18 | useEffect(() => {
19 | (async () => {
20 | if (!openBook && history.lastOpenedBookFileName) {
21 | console.log("historyhistory", history);
22 | setOpenBook(history.lastOpenedBookFileName);
23 | }
24 | })();
25 | }, [loadHistory, history]);
26 |
27 | // Determine filename and extension from openBook
28 | const fileName =
29 | openBook?.metadata && (openBook.metadata as any).fileName
30 | ? (openBook.metadata as any).fileName
31 | : null;
32 | const ext = fileName ? getFileExtension(fileName) : null;
33 |
34 | // EPUB reader
35 | if (ext === "epub" && (openBook as any)?.book) {
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | // PDF reader
44 | if (ext === "pdf" && (openBook as any)?.fileBytes) {
45 | return (
46 |
53 | );
54 | }
55 |
56 | // Render empty/error state
57 | const renderErrorBody = () => {
58 | if (!error) return null;
59 | const message = error instanceof Error ? error.message : String(error);
60 | const detail =
61 | error && typeof error === "object" && "detail" in (error as any)
62 | ? (error as any).detail
63 | : undefined;
64 |
65 | return (
66 | <>
67 |
70 | Error
71 | {message}
72 | {detail && (
73 | {detail}
74 | )}
75 | >
76 | );
77 | };
78 |
79 | return (
80 |
81 |
82 | {error ? (
83 | renderErrorBody()
84 | ) : (
85 | <>
86 |
87 |
88 |
89 |
90 | You don’t have any open book. Please go over to
91 |
95 | Library
96 |
97 |
98 | to open one.
99 |
100 | >
101 | )}
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/collections/CollectionsHeader.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | DialogTrigger,
9 | } from "@/components/ui/dialog";
10 | import { Input } from "@/components/ui/input";
11 | import { Textarea } from "@/components/ui/textarea";
12 | import { Plus } from "lucide-react";
13 |
14 | interface CollectionsHeaderProps {
15 | onCreateCollection: (name: string, description: string) => void;
16 | }
17 |
18 | export function CollectionsHeader({
19 | onCreateCollection,
20 | }: CollectionsHeaderProps) {
21 | const [isOpen, setIsOpen] = useState(false);
22 | const [formData, setFormData] = useState({ name: "", description: "" });
23 |
24 | const handleCreate = () => {
25 | if (!formData.name.trim()) return;
26 | onCreateCollection(formData.name, formData.description);
27 | setFormData({ name: "", description: "" });
28 | setIsOpen(false);
29 | };
30 |
31 | return (
32 |
33 |
34 | Collections
35 |
36 | Organize your books into custom collections
37 |
38 |
39 |
40 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/db/services/goals.services.ts:
--------------------------------------------------------------------------------
1 | import { and, eq, isNull, or } from "drizzle-orm";
2 | import { db } from "..";
3 | import { dailyReadingGoal, dailyReadingProgress, InsertGoals } from "../schema";
4 | import { today } from "@/lib/helpers/time";
5 |
6 | //// FIXED: prevent creating more than one general reading goal
7 | export async function createDailyReadingGoal(goal: InsertGoals) {
8 | let existing;
9 |
10 | if (goal.associatedBook) {
11 | // Check if a goal for this specific book already exists
12 | existing = await db
13 | .select()
14 | .from(dailyReadingGoal)
15 | .where(eq(dailyReadingGoal.associatedBook, goal.associatedBook));
16 | if (existing.length > 0) {
17 | throw new Error("A daily reading goal for this book already exists.");
18 | }
19 | } else {
20 | // Check if a general goal (no book associated) already exists
21 | existing = await db
22 | .select()
23 | .from(dailyReadingGoal)
24 | .where(isNull(dailyReadingGoal.associatedBook));
25 | if (existing.length > 0) {
26 | throw new Error("A general daily reading goal already exists.");
27 | }
28 | }
29 |
30 | // Create the new goal
31 | return await db.insert(dailyReadingGoal).values(goal);
32 | }
33 |
34 | export async function fetchAllGoals() {
35 | return await db.select().from(dailyReadingGoal);
36 | }
37 |
38 | export async function fetchProgressForToday() {
39 | return await db
40 | .select()
41 | .from(dailyReadingProgress)
42 | .where(eq(dailyReadingProgress.date, today()));
43 | }
44 |
45 | export async function createDailyReadingProgress(goalId: number, date: string) {
46 | const existing = await db
47 | .select()
48 | .from(dailyReadingProgress)
49 | .where(
50 | and(
51 | eq(dailyReadingProgress.goalId, goalId),
52 | eq(dailyReadingProgress.date, date)
53 | )
54 | );
55 | if (existing.length > 0) {
56 | throw new Error("Progress for this date already exists.");
57 | }
58 | return await db.insert(dailyReadingProgress).values({
59 | goalId,
60 | date,
61 | minutesRead: 0,
62 | });
63 | }
64 |
65 | export async function incrementMinutesReadForBook(
66 | bookId: string,
67 | incrementBy: number = 1
68 | ) {
69 | // Fetch all goals that are either unassociated or match the given bookId
70 | const goals = await db
71 | .select()
72 | .from(dailyReadingGoal)
73 | .where(
74 | or(
75 | isNull(dailyReadingGoal.associatedBook),
76 | eq(dailyReadingGoal.associatedBook, ""),
77 | eq(dailyReadingGoal.associatedBook, bookId)
78 | )
79 | );
80 |
81 | if (goals.length === 0) {
82 | return;
83 | }
84 |
85 | for (const goal of goals) {
86 | // Try to fetch today's progress for the goal
87 | const [progress] = await db
88 | .select()
89 | .from(dailyReadingProgress)
90 | .where(
91 | and(
92 | eq(dailyReadingProgress.goalId, goal.id),
93 | eq(dailyReadingProgress.date, today())
94 | )
95 | );
96 |
97 | let newMinutes: number;
98 |
99 | if (progress) {
100 | // Increment but do not exceed the goal's minutesToRead
101 | newMinutes = Math.min(
102 | progress.minutesRead + incrementBy,
103 | goal.minutesToRead
104 | );
105 |
106 | await db
107 | .update(dailyReadingProgress)
108 | .set({ minutesRead: newMinutes })
109 | .where(eq(dailyReadingProgress.id, progress.id));
110 | } else {
111 | // Create new progress record
112 | newMinutes = Math.min(incrementBy, goal.minutesToRead);
113 | await db
114 | .insert(dailyReadingProgress)
115 | .values({
116 | goalId: goal.id,
117 | date: today(),
118 | minutesRead: newMinutes,
119 | })
120 | .returning();
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/components/DailyReadingSummary.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from "@/components/ui/card";
2 | import { BookOpen, Clock } from "lucide-react";
3 | import { Button } from "./ui/button";
4 | import { Book, DailyBookRecord } from "@/db/schema";
5 | import { getBookReadDaysLastYear } from "@/db/services/stats.services";
6 | import { useStatsStore } from "@/stores/useStatsStore";
7 | import { format, isToday, isYesterday, parseISO } from "date-fns";
8 | import { trimBookTitle } from "@/lib/helpers/epub";
9 |
10 | type BookReadingSession = {
11 | book: Book;
12 | stat: DailyBookRecord;
13 | };
14 |
15 | export function DailyReadingSummary({
16 | bookSessions,
17 | day,
18 | }: {
19 | day: string;
20 | bookSessions: BookReadingSession[];
21 | }) {
22 | const { openBookStatsDialog } = useStatsStore((store) => store);
23 | const totalMinutes = bookSessions.reduce(
24 | (sum, session) => sum + session.stat.minutesRead,
25 | 0
26 | );
27 |
28 | const hours = Math.floor(totalMinutes / 60);
29 | const minutes = totalMinutes % 60;
30 |
31 | const formatTime = (mins: number) => {
32 | const h = Math.floor(mins / 60);
33 | const m = mins % 60;
34 | if (h > 0) {
35 | return `${h}h ${m}m`;
36 | }
37 | return `${m}m`;
38 | };
39 |
40 | async function handleBookStatsSummary(book: Book) {
41 | const allDays = await getBookReadDaysLastYear(book.bookId);
42 |
43 | openBookStatsDialog({ book, heatmap: allDays });
44 | }
45 |
46 | function getDayLabel(day: string) {
47 | const date = parseISO(day);
48 |
49 | if (isToday(date)) return "Today's";
50 | if (isYesterday(date)) return "Yesterday's";
51 |
52 | return format(date, "yyyy-MM-dd"); // or "MMM d, yyyy" if you want prettier
53 | }
54 |
55 | return (
56 |
57 |
58 |
59 |
60 | {getDayLabel(day)} Reading Summary
61 |
62 |
63 |
64 |
65 | {bookSessions.length < 1 && No book has been read this day }
66 | {bookSessions.map((session, index) => (
67 |
71 |
72 | {trimBookTitle(session.book.title)}
73 |
74 |
75 |
76 |
77 |
78 | {formatTime(session.stat.minutesRead)}
79 |
80 |
81 |
87 |
88 |
89 | ))}
90 |
91 |
92 |
93 |
94 |
95 | Total Reading Time
96 |
97 |
98 | {hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`}
99 |
100 |
101 |
102 | {totalMinutes} minutes across {bookSessions.length}{" "}
103 | {bookSessions.length === 1 ? "book" : "books"}
104 |
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/db/services/collections.services.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { collections, collectionBooks } from "../schema/collections";
3 | import { books } from "@/db/schema/book";
4 | import { eq, notInArray } from "drizzle-orm";
5 | import type {
6 | InsertCollection,
7 | InsertCollectionBook,
8 | } from "../schema/collections";
9 |
10 | export async function createCollection(data: InsertCollection) {
11 | await db.insert(collections).values(data);
12 |
13 | const [collection] = await db
14 | .select()
15 | .from(collections)
16 | .where(eq(collections.name, data.name));
17 |
18 | return collection;
19 | }
20 | export async function addBookToCollection(data: InsertCollectionBook) {
21 | const exists = await db
22 | .select()
23 | .from(collectionBooks)
24 | .where(
25 | eq(collectionBooks.collectionId, data.collectionId) &&
26 | eq(collectionBooks.bookId, data.bookId)
27 | );
28 |
29 | if (exists.length > 0) return exists[0]; // prevent duplicate
30 | const [link] = await db.insert(collectionBooks).values(data).returning();
31 | return link;
32 | }
33 |
34 | export async function removeBookFromCollection(
35 | collectionId: number,
36 | bookId: number
37 | ) {
38 | await db
39 | .delete(collectionBooks)
40 | .where(
41 | eq(collectionBooks.collectionId, collectionId) &&
42 | eq(collectionBooks.bookId, bookId)
43 | );
44 | }
45 |
46 | export async function getCollections() {
47 | const results = await db.select().from(collections);
48 | return results.map((c) => ({
49 | id: c.id,
50 | name: c.name,
51 | description: c.description,
52 | books: [],
53 | }));
54 | }
55 |
56 | // Get a single collection with its books
57 | export async function getCollectionWithBooks(collectionId: number) {
58 | const [collection] = await db
59 | .select()
60 | .from(collections)
61 | .where(eq(collections.id, collectionId));
62 |
63 | if (!collection) return null;
64 |
65 | const booksInCollection = await db
66 | .select({
67 | id: books.id,
68 | bookId: books.bookId,
69 | title: books.title,
70 | author: books.author,
71 | coverImagePath: books.coverImagePath,
72 | fileName: books.fileName,
73 | pages: books.pages,
74 | })
75 | .from(collectionBooks)
76 | .innerJoin(books, eq(collectionBooks.bookId, books.id))
77 | .where(eq(collectionBooks.collectionId, collectionId));
78 |
79 | const mappedBooks = booksInCollection.map((b) => ({
80 | id: b.id,
81 | title: b.title,
82 | author: b.author,
83 | cover: b.coverImagePath || "/default-cover.jpg", // fallback if null
84 | }));
85 |
86 | return {
87 | id: collection.id,
88 | name: collection.name,
89 | description: collection.description,
90 | books: mappedBooks,
91 | };
92 | }
93 |
94 | export async function deleteCollection(collectionId: number) {
95 | await db.delete(collections).where(eq(collections.id, collectionId));
96 | }
97 |
98 | export async function getBooksNotInCollection(collectionId: number) {
99 | // Get all book IDs that are already in the collection
100 | const booksInCollection = await db
101 | .select({ bookId: collectionBooks.bookId })
102 | .from(collectionBooks)
103 | .where(eq(collectionBooks.collectionId, collectionId));
104 |
105 | const bookIdsInCollection = booksInCollection.map((b) => b.bookId);
106 |
107 | // Select all books NOT in that collection
108 | const remainingBooks = await db
109 | .select({
110 | id: books.id,
111 | title: books.title,
112 | author: books.author,
113 | coverImagePath: books.coverImagePath,
114 | })
115 | .from(books)
116 | .where(notInArray(books.id, bookIdsInCollection));
117 |
118 | return remainingBooks.map((b) => ({
119 | id: b.id,
120 | title: b.title,
121 | author: b.author,
122 | cover: b.coverImagePath || "/default-cover.jpg",
123 | }));
124 | }
125 |
--------------------------------------------------------------------------------
/src/pages/Collections.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import { CollectionsGrid } from "@/components/collections/CollectionsGrid";
6 | import { CollectionsHeader } from "@/components/collections/CollectionsHeader";
7 | import { CollectionDetailView } from "@/components/collections/CollectionDetail";
8 | import { ChevronLeft } from "lucide-react";
9 | import {
10 | createCollection,
11 | deleteCollection,
12 | addBookToCollection,
13 | removeBookFromCollection,
14 | getCollections,
15 | getCollectionWithBooks,
16 | } from "@/db/services/collections.services"; // adjust import path if needed
17 |
18 | interface Book {
19 | id: number;
20 | title: string;
21 | author: string;
22 | cover: string;
23 | }
24 | interface Collection {
25 | id: number;
26 | name: string;
27 | description: string | null;
28 | books: Book[];
29 | }
30 |
31 | export function CollectionsPage() {
32 | const [collections, setCollections] = useState([]);
33 | const [selectedCollection, setSelectedCollection] =
34 | useState(null);
35 |
36 | useEffect(() => {
37 | async function loadCollections() {
38 | const data = await getCollections();
39 | setCollections(data);
40 | }
41 | loadCollections();
42 | }, []);
43 |
44 | async function handleCreateCollection(name: string, description: string) {
45 | const newCollection = await createCollection({ name, description });
46 | console.log("new collectionsss", newCollection);
47 | setCollections((prev) => [...prev, { ...newCollection, books: [] }]);
48 | }
49 |
50 | async function handleDeleteCollection(id: number) {
51 | await deleteCollection(id);
52 | setCollections((prev) => prev.filter((c) => c.id !== id));
53 | if (selectedCollection?.id === id) setSelectedCollection(null);
54 | }
55 |
56 | async function handleViewCollection(collectionId: number) {
57 | const fullCollection = await getCollectionWithBooks(collectionId);
58 | if (fullCollection) setSelectedCollection(fullCollection);
59 | }
60 |
61 | async function handleAddBookToCollection(bookId: number) {
62 | if (!selectedCollection) return;
63 | await addBookToCollection({
64 | collectionId: selectedCollection.id,
65 | bookId,
66 | });
67 | const updated = await getCollectionWithBooks(selectedCollection.id);
68 | if (updated) setSelectedCollection(updated);
69 | }
70 |
71 | async function handleRemoveBookFromCollection(bookId: number) {
72 | if (!selectedCollection) return;
73 | await removeBookFromCollection(selectedCollection.id, bookId);
74 | const updated = await getCollectionWithBooks(selectedCollection.id);
75 | if (updated) setSelectedCollection(updated);
76 | }
77 |
78 | if (selectedCollection) {
79 | return (
80 |
81 |
89 |
90 |
95 |
96 | );
97 | }
98 |
99 | return (
100 |
101 |
102 |
103 |
108 |
109 | {collections.length === 0 && (
110 |
111 |
112 | No collections yet. Create one to organize your books!
113 |
114 |
115 | )}
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/lib/helpers/pdf.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Lightweight PDF helpers for use in the renderer (Tauri/Electron/web).
3 | *
4 | * Exposes helpers that accept an ArrayBuffer (or Uint8Array) and:
5 | * - extract a small set of metadata (title, author, pages, dates)
6 | * - render the first page to a JPEG data URL (thumbnail / cover)
7 | *
8 | * Notes:
9 | * - This file uses pdfjs-dist. It purposely keeps a very small surface (no React).
10 | * - It returns data URLs for cover images (so sync-books.ts can call saveImage()).
11 | */
12 |
13 | import * as pdfjsLib from "pdfjs-dist";
14 | // If your bundler needs worker configured, do it once globally in your app bootstrap:
15 | // import workerSrc from "pdfjs-dist/build/pdf.worker.entry";
16 | // pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc as unknown as string;
17 |
18 | export type PdfMetadata = {
19 | title?: string | null;
20 | author?: string | null;
21 | subject?: string | null;
22 | keywords?: string | null;
23 | creator?: string | null;
24 | producer?: string | null;
25 | creationDate?: string | null;
26 | modDate?: string | null;
27 | pages: number;
28 | };
29 |
30 | function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer {
31 | if (u8 instanceof ArrayBuffer) return u8;
32 | return new Uint8Array(u8).buffer;
33 | }
34 |
35 | /**
36 | * Extract minimal metadata from a PDF ArrayBuffer.
37 | */
38 | export async function getPdfMetadataFromArrayBuffer(
39 | buf: Uint8Array | ArrayBuffer
40 | ): Promise {
41 | const arrayBuffer = toArrayBuffer(buf);
42 | const loadingTask = (pdfjsLib as any).getDocument({ data: arrayBuffer });
43 | const pdf = await loadingTask.promise;
44 |
45 | try {
46 | const md = await pdf
47 | .getMetadata()
48 | .catch(() => ({ info: {}, metadata: null }));
49 | const info = md?.info ?? {};
50 | return {
51 | title: info?.Title ?? null,
52 | author: info?.Author ?? null,
53 | subject: info?.Subject ?? null,
54 | keywords: info?.Keywords ?? null,
55 | creator: info?.Creator ?? null,
56 | producer: info?.Producer ?? null,
57 | creationDate: info?.CreationDate ?? null,
58 | modDate: info?.ModDate ?? null,
59 | pages: pdf.numPages,
60 | };
61 | } finally {
62 | try {
63 | await pdf.destroy();
64 | } catch {
65 | /* ignore */
66 | }
67 | }
68 | }
69 |
70 | /**
71 | * Render the first PDF page (or page=1) to a JPEG data URL.
72 | * Returns a string like "data:image/jpeg;base64,...."
73 | *
74 | * scale controls resolution; 1.0 is native, 1.5..2.0 produce sharper thumbnails.
75 | */
76 | export async function getPdfCoverImageFromArrayBuffer(
77 | buf: Uint8Array | ArrayBuffer,
78 | { page = 1, scale = 1.5 }: { page?: number; scale?: number } = {}
79 | ): Promise {
80 | const arrayBuffer = toArrayBuffer(buf);
81 | const loadingTask = (pdfjsLib as any).getDocument({ data: arrayBuffer });
82 | const pdf = await loadingTask.promise;
83 |
84 | try {
85 | const pdfPage = await pdf.getPage(page);
86 | const viewport = pdfPage.getViewport({ scale });
87 | // create canvas
88 | const canvas = document.createElement("canvas");
89 | const ctx = canvas.getContext("2d");
90 | if (!ctx) throw new Error("Canvas 2D context not available");
91 |
92 | // size canvas for device pixel ratio to improve sharpness on high-DPI screens
93 | const dpr = window.devicePixelRatio || 1;
94 | canvas.width = Math.round(viewport.width * dpr);
95 | canvas.height = Math.round(viewport.height * dpr);
96 | canvas.style.width = `${Math.round(viewport.width)}px`;
97 | canvas.style.height = `${Math.round(viewport.height)}px`;
98 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
99 | ctx.clearRect(0, 0, viewport.width, viewport.height);
100 |
101 | await pdfPage.render({ canvasContext: ctx, viewport }).promise;
102 |
103 | // encode as JPEG (smaller than PNG), quality 0.85
104 | const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
105 | return dataUrl;
106 | } finally {
107 | try {
108 | await pdf.destroy();
109 | } catch {
110 | /* ignore */
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/Heatmap.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { parseISO, subDays, format, isSameDay } from "date-fns";
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipTrigger,
7 | } from "@/components/ui/tooltip";
8 | import { formatMinutesToTime } from "@/lib/helpers/time";
9 |
10 | interface HeatmapProps {
11 | endDate: string;
12 | data: { date: string; count: number }[];
13 | maxLevel?: number; // how many color levels, default 4
14 | maxCount?: number; // the brightest level threshold, default 60
15 | }
16 |
17 | const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
18 |
19 | export default function Heatmap({
20 | endDate,
21 | data,
22 | maxLevel = 4,
23 | maxCount = 60,
24 | }: HeatmapProps) {
25 | const days = useMemo(() => {
26 | const end = parseISO(endDate);
27 |
28 | // Base start is 364 days before end (1 year - 1 day)
29 | let start = subDays(end, 364);
30 |
31 | // Align start to the previous Sunday so weeks always start on Sunday
32 | const startDay = start.getDay(); // 0 = Sun
33 | if (startDay !== 0) {
34 | start = subDays(start, startDay);
35 | }
36 |
37 | // NOTE: Do NOT extend the end date to the following Saturday.
38 | // We intentionally stop at the provided `end` so the grid won't render future days.
39 | const allDays: { date: string; count: number; weekday: number }[] = [];
40 |
41 | for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
42 | const dateStr = format(d, "yyyy-MM-dd");
43 | const existing = data.find((x) => isSameDay(parseISO(x.date), d));
44 | allDays.push({
45 | date: dateStr,
46 | count: existing ? existing.count : 0,
47 | weekday: d.getDay(), // 0 = Sun, 6 = Sat
48 | });
49 | }
50 | return allDays;
51 | }, [endDate, data]);
52 |
53 | // Chunk by weeks (7 days each). Because start is aligned to Sunday,
54 | // each chunk begins on Sunday. The final chunk may be shorter and will
55 | // only contain days up to `endDate`.
56 | const weeks = useMemo(() => {
57 | const chunked: { date: string; count: number; weekday: number }[][] = [];
58 | for (let i = 0; i < days.length; i += 7) {
59 | chunked.push(days.slice(i, i + 7));
60 | }
61 | return chunked;
62 | }, [days]);
63 |
64 | const getLevelClass = (count: number) => {
65 | if (count <= 0) return "bg-muted";
66 |
67 | const step = maxCount / maxLevel;
68 | let level = Math.ceil(count / step);
69 |
70 | level = Math.min(level, maxLevel);
71 |
72 | const intensityMap = {
73 | 1: "bg-primary/30",
74 | 2: "bg-primary/50",
75 | 3: "bg-primary/70",
76 | 4: "bg-primary",
77 | };
78 |
79 | return intensityMap[level as keyof typeof intensityMap] || "bg-primary";
80 | };
81 | return (
82 |
83 |
94 | {/* Weekday labels column */}
95 |
96 | {WEEKDAY_LABELS.map((label) => (
97 |
98 | {label}
99 |
100 | ))}
101 |
102 |
103 | {/* Heatmap weeks */}
104 |
105 | {weeks.map((week, i) => (
106 |
107 | {week.map((day) => (
108 |
109 |
110 |
115 |
116 |
117 |
118 | {format(parseISO(day.date), "MMMM d, yyyy")} | Read{" "}
119 | {formatMinutesToTime(day.count)}
120 |
121 |
122 |
123 | ))}
124 |
125 | ))}
126 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/src/pages/Library.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import useSettingsStore from "@/stores/useSettingsStore";
3 | import { BookCardSkeleton } from "@/components/BookCardSkeleton";
4 | import { Link } from "react-router";
5 | import { MoveRight, RefreshCcw } from "lucide-react";
6 | import BookCard from "@/components/BookCard";
7 | import { syncBooksInFileSystemWithDb } from "@/lib/helpers/fs";
8 | import { fetchAllDbBooks } from "@/db/services/books.services";
9 | import { useBookCovers } from "@/hooks/useBookCover";
10 | import { Book } from "@/db/schema";
11 | import { Button } from "@/components/ui/button";
12 | import { Progress } from "@/components/ui/progress";
13 |
14 | export function LibraryPage() {
15 | const [libraryBooks, setLibraryBooks] = useState([]);
16 | const [loading, setLoading] = useState(false);
17 | const { settings } = useSettingsStore((state) => state);
18 |
19 | const [syncProgress, setSyncProgress] = useState({
20 | totalBooks: 0,
21 | currentSyncedBooksCount: 0,
22 | });
23 | const [currentLoadingBook, setCurrentLoadingBook] = useState("");
24 |
25 | const coverImages = useBookCovers(libraryBooks, "image/jpeg");
26 |
27 | // Fetch books from DB (no sync)
28 | async function fetchBooksFromDb() {
29 | setLoading(true);
30 | try {
31 | const booksInDb = await fetchAllDbBooks();
32 | setLibraryBooks(booksInDb);
33 | return booksInDb;
34 | } catch (error) {
35 | console.log("Error fetching books from DB:", error);
36 | return [];
37 | } finally {
38 | setLoading(false);
39 | }
40 | }
41 |
42 | // Sync file system with DB, then fetch
43 | async function syncAndFetchBooks() {
44 | setLoading(true);
45 | try {
46 | await syncBooksInFileSystemWithDb({
47 | settings,
48 | setCurrentLoadingBook,
49 | setSyncProgress,
50 | });
51 | const booksInDb = await fetchAllDbBooks();
52 | setLibraryBooks(booksInDb);
53 | } catch (error) {
54 | console.log("Error syncing the DB", error);
55 | } finally {
56 | setLoading(false);
57 | }
58 | }
59 |
60 | // Initial effect: only sync if DB is empty
61 | useEffect(() => {
62 | async function init() {
63 | const books = await fetchBooksFromDb();
64 | if (books.length === 0) {
65 | await syncAndFetchBooks();
66 | }
67 | }
68 | init();
69 | // eslint-disable-next-line react-hooks/exhaustive-deps
70 | }, [settings?.libraryFolderPath]);
71 |
72 | return (
73 |
74 |
75 |
76 |
77 | My Library
78 |
79 | Your personal collection
80 |
81 |
85 |
86 |
87 | {loading && (
88 |
89 |
96 |
97 | Processing:{currentLoadingBook}
98 |
99 |
100 | )}
101 |
102 | {libraryBooks.length === 0 && !loading && (
103 |
104 | No books found in your library. Perhaps change your library folder to
105 | somewhere else
106 |
110 | here
111 |
112 |
113 |
114 | )}
115 |
116 | {loading && (
117 | <>
118 | {[...Array(4)].map((_, i) => (
119 |
120 | ))}
121 | >
122 | )}
123 | {libraryBooks.map((book, index) => (
124 |
130 | ))}
131 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | showCloseButton = true,
51 | ...props
52 | }: React.ComponentProps & {
53 | showCloseButton?: boolean
54 | }) {
55 | return (
56 |
57 |
58 |
66 | {children}
67 | {showCloseButton && (
68 |
72 |
73 | Close
74 |
75 | )}
76 |
77 |
78 | )
79 | }
80 |
81 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
82 | return (
83 |
88 | )
89 | }
90 |
91 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
92 | return (
93 |
101 | )
102 | }
103 |
104 | function DialogTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function DialogDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Dialog,
132 | DialogClose,
133 | DialogContent,
134 | DialogDescription,
135 | DialogFooter,
136 | DialogHeader,
137 | DialogOverlay,
138 | DialogPortal,
139 | DialogTitle,
140 | DialogTrigger,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/collections/CollectionDetail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useMemo, useState } from "react";
4 | import { Input } from "@/components/ui/input";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Table,
8 | TableBody,
9 | TableCell,
10 | TableHead,
11 | TableHeader,
12 | TableRow,
13 | } from "@/components/ui/table";
14 | import { getBooksNotInCollection } from "@/db/services/collections.services";
15 | import { BookSelector } from "@/components/BookSelector"; // import your generic selector
16 | import { Book as DbBook } from "@/db/schema/book";
17 | interface Book {
18 | id: number;
19 | title: string;
20 | author: string;
21 | cover: string;
22 | }
23 |
24 | interface Collection {
25 | id: number;
26 | name: string;
27 | description: string | null;
28 | books: Book[];
29 | }
30 |
31 | interface CollectionDetailViewProps {
32 | collection: Collection;
33 | onAddBook: (bookId: number) => void;
34 | onRemoveBook: (bookId: number) => void;
35 | }
36 |
37 | export function CollectionDetailView({
38 | collection,
39 | onAddBook,
40 | onRemoveBook,
41 | }: CollectionDetailViewProps) {
42 | const [booksNotInCollection, setBooksNotInCollection] = useState([]);
43 | const [searchTerm, setSearchTerm] = useState("");
44 |
45 | useEffect(() => {
46 | async function fetchBooks() {
47 | const availableBooks = await getBooksNotInCollection(collection.id);
48 | setBooksNotInCollection(availableBooks);
49 | }
50 | fetchBooks();
51 | }, [collection.id, collection.books]);
52 |
53 | const filteredBooks = useMemo(() => {
54 | if (!searchTerm) return booksNotInCollection;
55 | return booksNotInCollection.filter(
56 | (b) =>
57 | b.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
58 | b.author.toLowerCase().includes(searchTerm.toLowerCase())
59 | );
60 | }, [searchTerm, booksNotInCollection]);
61 |
62 | const handleAddBook = async (book: DbBook) => {
63 | await onAddBook(book.id);
64 | setBooksNotInCollection((prev) => prev.filter((b) => b.id !== book.id));
65 | };
66 |
67 | const handleRemoveBook = async (bookId: number) => {
68 | await onRemoveBook(bookId);
69 | const updatedBooks = await getBooksNotInCollection(collection.id);
70 | setBooksNotInCollection(updatedBooks);
71 | };
72 |
73 | return (
74 |
75 |
76 |
77 | {collection.name}
78 |
79 | {collection.description && (
80 | {collection.description}
81 | )}
82 |
83 |
84 | {/* Books in Collection */}
85 |
86 |
87 | Books in Collection ({collection.books.length})
88 |
89 | {collection.books.length > 0 ? (
90 |
91 |
92 |
93 | Title
94 | Author
95 | Action
96 |
97 |
98 |
99 | {collection.books.map((book) => (
100 |
101 | {book.title}
102 | {book.author}
103 |
104 |
112 |
113 |
114 | ))}
115 |
116 |
117 | ) : (
118 |
119 | No books in this collection yet.
120 |
121 | )}
122 |
123 |
124 | {/* Add Books Section */}
125 |
126 |
127 | Add Books to Collection
128 |
129 |
130 | b.id)} // filter only books not in collection
132 | onSelect={handleAddBook}
133 | trigger={}
134 | />
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/src/stores/useReaderStore.ts:
--------------------------------------------------------------------------------
1 | import type { OpenEpub } from "@/lib/types/epub"; // existing import (if you have)
2 | import type { OpenPdf } from "@/lib/types/pdf";
3 | import { BaseDirectory, readFile } from "@tauri-apps/plugin-fs";
4 | import { loadEpubBook } from "epubix";
5 | import { create } from "zustand";
6 | import useSettingsStore from "./useSettingsStore";
7 | import { DEFAULT_LIBRARY_FOLDER_PATH, STORE_KEYS } from "@/lib/constants";
8 | import { db } from "@/db";
9 | import { books } from "@/db/schema";
10 | import { eq } from "drizzle-orm";
11 | // Optional: pdf metadata helper if you have one
12 | import { getPdfMetadataFromArrayBuffer } from "@/lib/helpers/pdf";
13 |
14 | type OpenBook = OpenEpub | OpenPdf;
15 | interface ReaderStore {
16 | openBook: OpenBook | null; // <- changed from OpenEpub | null
17 | openChapterHref: string | null;
18 | isSettingsDialogOpen: boolean;
19 | setOpenBook: (fileName: string) => Promise;
20 | setOpenChapterHref: (href: string) => void;
21 | toggleSettingsDialog: (state: boolean) => void;
22 | closeBook: () => void;
23 | loading: boolean;
24 | error: { message: string; detail?: string } | null;
25 | }
26 |
27 | export const useReaderStore = create((set) => ({
28 | loading: false,
29 | error: null,
30 | openBook: null,
31 | openChapterHref: null,
32 | isSettingsDialogOpen: false,
33 | closeBook: () => {
34 | set({ openBook: null, openChapterHref: null });
35 | },
36 | setOpenBook: async (fileName: string) => {
37 | const store = useSettingsStore.getState();
38 | let currentLibraryPath =
39 | store.settings?.libraryFolderPath || DEFAULT_LIBRARY_FOLDER_PATH;
40 |
41 | const bookInDb = await db.query.books.findFirst({
42 | where: eq(books.fileName, fileName),
43 | });
44 |
45 | if (!bookInDb) {
46 | set({
47 | error: {
48 | message: "Book not found!",
49 | detail:
50 | "The book you are trying to access doesn't exist in the local caching database",
51 | },
52 | });
53 | return;
54 | }
55 |
56 | try {
57 | const fileLower = (fileName || "").toLowerCase().trim();
58 |
59 | // PDF path (detect by extension)
60 | if (fileLower.endsWith(".pdf")) {
61 | // readFile to get Uint8Array
62 | const bytes = await readFile(`${currentLibraryPath}/${fileName}`, {
63 | baseDir: BaseDirectory.Document,
64 | });
65 |
66 | // Optionally extract metadata (title, author, pages)
67 | let pdfMeta = {
68 | title: bookInDb.title,
69 | author: bookInDb.author,
70 | pages: null,
71 | } as any;
72 | try {
73 | const md = await getPdfMetadataFromArrayBuffer(bytes);
74 | pdfMeta = {
75 | title: md.title ?? bookInDb.title,
76 | author: md.author ?? bookInDb.author,
77 | pages: md.pages ?? null,
78 | fileName,
79 | };
80 | } catch (mdErr) {
81 | // Non-fatal: keep fallback metadata from DB/filename
82 | pdfMeta = {
83 | title: bookInDb.title,
84 | author: bookInDb.author,
85 | fileName,
86 | };
87 | }
88 |
89 | const openPdf: OpenPdf = {
90 | type: "pdf",
91 | metadata: pdfMeta,
92 | fileBytes: bytes,
93 | };
94 |
95 | localStorage.setItem(STORE_KEYS.lastOpenedBook, fileName);
96 | set({ openBook: openPdf });
97 | return;
98 | }
99 |
100 | // Otherwise, assume EPUB (existing flow)
101 | const ep = await readFile(`${currentLibraryPath}/${fileName}`, {
102 | baseDir: BaseDirectory.Document,
103 | });
104 |
105 | const book = await loadEpubBook(ep);
106 |
107 | localStorage.setItem(STORE_KEYS.lastOpenedBook, fileName);
108 |
109 | // include discriminant type
110 | const openEpub: OpenEpub = {
111 | metadata: { ...book.metadata, fileName },
112 | book,
113 | };
114 |
115 | set({ openBook: openEpub });
116 | } catch (error: any) {
117 | set({
118 | error: {
119 | message: `Sorry, Couldn't open the book '${
120 | bookInDb?.title || "Unknown Title"
121 | }'`,
122 | detail:
123 | error?.message ||
124 | `It's probably deleted or that you have changed the path. Make sure the file exists inside 'Documents/${store.settings?.libraryFolderPath}'`,
125 | },
126 | });
127 | }
128 | },
129 | setOpenChapterHref: (href) => {
130 | set({ openChapterHref: href });
131 | },
132 | toggleSettingsDialog: (state) => {
133 | set({ isSettingsDialogOpen: state });
134 | },
135 | }));
136 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Drawer as DrawerPrimitive } from "vaul"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Drawer({
7 | ...props
8 | }: React.ComponentProps) {
9 | return
10 | }
11 |
12 | function DrawerTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return
16 | }
17 |
18 | function DrawerPortal({
19 | ...props
20 | }: React.ComponentProps) {
21 | return
22 | }
23 |
24 | function DrawerClose({
25 | ...props
26 | }: React.ComponentProps) {
27 | return
28 | }
29 |
30 | function DrawerOverlay({
31 | className,
32 | ...props
33 | }: React.ComponentProps) {
34 | return (
35 |
43 | )
44 | }
45 |
46 | function DrawerContent({
47 | className,
48 | children,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
66 |
67 | {children}
68 |
69 |
70 | )
71 | }
72 |
73 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
83 | )
84 | }
85 |
86 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
87 | return (
88 |
93 | )
94 | }
95 |
96 | function DrawerTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | )
107 | }
108 |
109 | function DrawerDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | )
120 | }
121 |
122 | export {
123 | Drawer,
124 | DrawerPortal,
125 | DrawerOverlay,
126 | DrawerTrigger,
127 | DrawerClose,
128 | DrawerContent,
129 | DrawerHeader,
130 | DrawerFooter,
131 | DrawerTitle,
132 | DrawerDescription,
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/PdfReader.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import { Document, Page, pdfjs } from "react-pdf";
3 | import "react-pdf/dist/Page/AnnotationLayer.css";
4 | import "react-pdf/dist/Page/TextLayer.css";
5 | import { PdfHeader } from "./PdfReaderHeader";
6 | import { Theme } from "./theme-provider";
7 | import { THEME_STORAGE_KEY } from "@/lib/constants";
8 | import { useReadingTracker } from "@/hooks/useReadingTimer";
9 | import { generateBookId } from "@/lib/helpers/epub";
10 | import { OpenPdf } from "@/lib/types/pdf";
11 | import { useHistoryStore } from "@/stores/useHistoryStore";
12 |
13 | pdfjs.GlobalWorkerOptions.workerSrc = new URL(
14 | "pdfjs-dist/build/pdf.worker.min.mjs",
15 | import.meta.url
16 | ).toString();
17 |
18 | type PdfReaderProps = {
19 | data: ArrayBuffer | Uint8Array | File | string;
20 | initialPage?: number;
21 | pageWindow?: number;
22 | fileName?: string;
23 | openBook: OpenPdf;
24 | };
25 |
26 | export default function PdfReader({
27 | data,
28 | openBook,
29 | initialPage = 1,
30 | pageWindow = 1,
31 | fileName = "document.pdf",
32 | }: PdfReaderProps) {
33 | const [numPages, setNumPages] = useState(0);
34 | const [currentPage, setCurrentPage] = useState(initialPage);
35 | const [fileData, setFileData] = useState(
36 | null
37 | );
38 | const [currentTheme, setCurrentTheme] = useState("light");
39 | const containerRef = useRef(null);
40 | const [containerWidth, setContainerWidth] = useState(0);
41 | const [zoom, setZoom] = useState(1);
42 |
43 | const { addLastOpenedPage, findLastOpenedPage } = useHistoryStore();
44 |
45 | useReadingTracker({
46 | bookId: generateBookId({
47 | ...Object.fromEntries(
48 | Object.entries(openBook.metadata).map(([key, value]) => [
49 | key,
50 | value ?? undefined,
51 | ])
52 | ),
53 | fileName: openBook.metadata.fileName,
54 | }),
55 | });
56 |
57 | // Load file data
58 | useEffect(() => {
59 | if (data instanceof File) {
60 | const reader = new FileReader();
61 | reader.onload = () => setFileData(reader.result as ArrayBuffer);
62 | reader.readAsArrayBuffer(data);
63 | } else if (data instanceof Uint8Array) {
64 | setFileData(new Uint8Array(data).buffer);
65 | } else {
66 | setFileData(data as string | ArrayBuffer);
67 | }
68 | }, [data]);
69 |
70 | // Track container width
71 | useEffect(() => {
72 | const handleResize = () => {
73 | if (containerRef.current)
74 | setContainerWidth(containerRef.current.clientWidth);
75 | };
76 | handleResize();
77 | window.addEventListener("resize", handleResize);
78 | return () => window.removeEventListener("resize", handleResize);
79 | }, []);
80 |
81 | // Load theme
82 | useEffect(() => {
83 | const theme = localStorage.getItem(THEME_STORAGE_KEY);
84 | setCurrentTheme(theme === "dark" ? "dark" : "light");
85 | }, []);
86 |
87 | // Load last opened page from store
88 | useEffect(() => {
89 | if (!fileName) return;
90 | (async () => {
91 | const savedPage = findLastOpenedPage(fileName);
92 | if (savedPage) setCurrentPage(savedPage);
93 | })();
94 | }, [fileName]);
95 |
96 | // Save current page to store whenever it changes
97 | useEffect(() => {
98 | if (!fileName) return;
99 | addLastOpenedPage({ fileName, pageNum: currentPage });
100 | }, [currentPage, fileName]);
101 |
102 | function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
103 | setNumPages(numPages);
104 | }
105 |
106 | const startPage = currentPage;
107 | const endPage = currentPage;
108 |
109 | return (
110 |
119 | setCurrentPage(pageNum)}
121 | currentPage={currentPage}
122 | numPages={numPages}
123 | zoom={zoom}
124 | onPrev={() => setCurrentPage((p) => Math.max(p - 1, 1))}
125 | onNext={() => setCurrentPage((p) => Math.min(p + 1, numPages))}
126 | onZoomIn={() => setZoom((z) => Math.min(z + 0.1, 1))}
127 | onZoomOut={() => setZoom((z) => Math.max(z - 0.1, 0.25))}
128 | />
129 |
130 | {fileData && (
131 |
136 | {Array.from({ length: endPage - startPage + 1 }, (_, i) => (
137 |
147 | ))}
148 |
149 | )}
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/src/layouts/Root.tsx:
--------------------------------------------------------------------------------
1 | import BookSummaryDialog from "@/components/dialogs/BookSummaryDialog";
2 | import { ThemeProvider } from "@/components/theme-provider";
3 | import { Button } from "@/components/ui/button";
4 | import { cn } from "@/lib/utils";
5 | import { useHistoryStore } from "@/stores/useHistoryStore";
6 | import useSettingsStore from "@/stores/useSettingsStore";
7 | import {
8 | BookOpen,
9 | BookOpenText,
10 | Flame,
11 | Library,
12 | Menu,
13 | Settings,
14 | Target,
15 | TestTubeDiagonal,
16 | } from "lucide-react";
17 | import { useEffect, useState } from "react";
18 | import { NavLink, Outlet, useLocation } from "react-router";
19 |
20 | export default function RootLayout() {
21 | const [sidebarOpen, setSidebarOpen] = useState(false);
22 | const location = useLocation();
23 | const menuItems = [
24 | { path: "/", label: "Home", icon: BookOpen },
25 | { path: "/library", label: "Library", icon: BookOpenText },
26 | { path: "/collections", label: "Collections", icon: Library },
27 | { path: "/streak", label: "Streak", icon: Flame },
28 | { path: "/goals", label: "Goals", icon: Target },
29 | { path: "/settings", label: "Settings", icon: Settings },
30 | ];
31 |
32 | const fetchSettings = useSettingsStore((store) => store.fetchSettings);
33 | const loadHistory = useHistoryStore((s) => s.loadHistory);
34 |
35 | useEffect(() => {}, []);
36 |
37 | useEffect(() => {
38 | async function init() {
39 | await fetchSettings();
40 | await loadHistory();
41 | }
42 |
43 | init();
44 | }, []);
45 | return (
46 |
47 |
48 |
103 | {/* Overlay */}
104 | {sidebarOpen && (
105 | setSidebarOpen(false)}
108 | />
109 | )}
110 |
111 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/src/components/ui/item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 | import { Separator } from "@/components/ui/separator"
7 |
8 | function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
9 | return (
10 |
16 | )
17 | }
18 |
19 | function ItemSeparator({
20 | className,
21 | ...props
22 | }: React.ComponentProps ) {
23 | return (
24 |
30 | )
31 | }
32 |
33 | const itemVariants = cva(
34 | "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
35 | {
36 | variants: {
37 | variant: {
38 | default: "bg-transparent",
39 | outline: "border-border",
40 | muted: "bg-muted/50",
41 | },
42 | size: {
43 | default: "p-4 gap-4 ",
44 | sm: "py-3 px-4 gap-2.5",
45 | },
46 | },
47 | defaultVariants: {
48 | variant: "default",
49 | size: "default",
50 | },
51 | }
52 | )
53 |
54 | function Item({
55 | className,
56 | variant = "default",
57 | size = "default",
58 | asChild = false,
59 | ...props
60 | }: React.ComponentProps<"div"> &
61 | VariantProps & { asChild?: boolean }) {
62 | const Comp = asChild ? Slot : "div"
63 | return (
64 |
71 | )
72 | }
73 |
74 | const itemMediaVariants = cva(
75 | "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
76 | {
77 | variants: {
78 | variant: {
79 | default: "bg-transparent",
80 | icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
81 | image:
82 | "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
83 | },
84 | },
85 | defaultVariants: {
86 | variant: "default",
87 | },
88 | }
89 | )
90 |
91 | function ItemMedia({
92 | className,
93 | variant = "default",
94 | ...props
95 | }: React.ComponentProps<"div"> & VariantProps) {
96 | return (
97 |
103 | )
104 | }
105 |
106 | function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
107 | return (
108 |
116 | )
117 | }
118 |
119 | function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
120 | return (
121 |
129 | )
130 | }
131 |
132 | function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
133 | return (
134 | a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
139 | className
140 | )}
141 | {...props}
142 | />
143 | )
144 | }
145 |
146 | function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
147 | return (
148 |
153 | )
154 | }
155 |
156 | function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
157 | return (
158 |
166 | )
167 | }
168 |
169 | function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
170 | return (
171 |
179 | )
180 | }
181 |
182 | export {
183 | Item,
184 | ItemMedia,
185 | ItemContent,
186 | ItemActions,
187 | ItemGroup,
188 | ItemSeparator,
189 | ItemTitle,
190 | ItemDescription,
191 | ItemHeader,
192 | ItemFooter,
193 | }
194 |
--------------------------------------------------------------------------------
/src/components/BookSelector.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | DialogFooter,
11 | } from "@/components/ui/dialog";
12 | import { Button } from "@/components/ui/button";
13 | import { Input } from "@/components/ui/input";
14 | import { fetchAllDbBooks } from "@/db/services/books.services";
15 | import { useBookCovers } from "@/hooks/useBookCover";
16 | import { Book } from "@/db/schema";
17 |
18 | interface BookSelectorProps {
19 | onSelect: (book: Book) => void;
20 | excludedBooks?: number[];
21 | trigger?: React.ReactNode;
22 | }
23 |
24 | export function BookSelector({
25 | onSelect,
26 | excludedBooks,
27 | trigger,
28 | }: BookSelectorProps) {
29 | const [books, setBooks] = useState([]);
30 | const [filteredBooks, setFilteredBooks] = useState([]);
31 | const [loading, setLoading] = useState(false);
32 | const [searchTerm, setSearchTerm] = useState("");
33 | const [selectedBook, setSelectedBook] = useState(null);
34 | const [open, setOpen] = useState(false);
35 |
36 | const coverImages = useBookCovers(filteredBooks, "image/jpeg");
37 |
38 | useEffect(() => {
39 | async function fetchBooks() {
40 | setLoading(true);
41 | try {
42 | let allBooks = await fetchAllDbBooks();
43 | setBooks(allBooks);
44 |
45 | if (excludedBooks) {
46 | allBooks = allBooks.filter((b) => !excludedBooks.includes(b.id));
47 | }
48 | console.log("filters books", allBooks);
49 | setFilteredBooks(allBooks);
50 | } catch (err) {
51 | console.error("Error fetching books:", err);
52 | } finally {
53 | setLoading(false);
54 | }
55 | }
56 | fetchBooks();
57 | }, []);
58 | /*
59 | useEffect(() => {
60 | if (!searchTerm) {
61 | setFilteredBooks(books);
62 | } else {
63 | const term = searchTerm.toLowerCase();
64 | setFilteredBooks(
65 | books.filter(
66 | (b) =>
67 | b.title.toLowerCase().includes(term) ||
68 | b.author.toLowerCase().includes(term)
69 | )
70 | );
71 | }
72 | }, [searchTerm, books]); */
73 |
74 | useEffect(() => {
75 | if (searchTerm) {
76 | const term = searchTerm.toLowerCase();
77 | setFilteredBooks(
78 | books.filter(
79 | (b) =>
80 | b.title.toLowerCase().includes(term) ||
81 | b.author.toLowerCase().includes(term)
82 | )
83 | );
84 | }
85 | }, [searchTerm]);
86 | const handleConfirm = () => {
87 | if (selectedBook) {
88 | onSelect(selectedBook);
89 | setSelectedBook(null);
90 | setOpen(false);
91 | }
92 | };
93 |
94 | return (
95 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/src/hooks/useReadingTimer.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { listen } from "@tauri-apps/api/event";
3 | import { format } from "date-fns";
4 | import {
5 | updateBookStats,
6 | updateDailyUserStats,
7 | } from "@/db/services/stats.services";
8 | import { incrementMinutesReadForBook } from "@/db/services/goals.services";
9 |
10 | interface ReadingTrackerOptions {
11 | bookId: string;
12 | }
13 |
14 | interface ReadingTrackerHookOptions {
15 | /**
16 | * If true, the hook will update a reactive minutesRead state each minute.
17 | * Default: false (recommended to avoid unnecessary re-renders).
18 | */
19 | reactive?: boolean;
20 | }
21 |
22 | export function useReadingTracker(
23 | { bookId }: ReadingTrackerOptions,
24 | { reactive = false }: ReadingTrackerHookOptions = {}
25 | ) {
26 | // Internal refs (no rerenders when these change)
27 | const isActiveRef = useRef(true);
28 | const minutesRef = useRef(0);
29 | const lastActivityRef = useRef(Date.now());
30 | const intervalRef = useRef(null);
31 |
32 | // Public reactive state — only used if `reactive` is true.
33 | const [minutesRead, setMinutesRead] = useState(0);
34 |
35 | const INACTIVITY_LIMIT = 10 * 60 * 1000; // 10 minutes
36 |
37 | // Activity listeners: attach once. Use refs to avoid causing re-subscriptions.
38 | useEffect(() => {
39 | const updateActivity = () => {
40 | lastActivityRef.current = Date.now();
41 | isActiveRef.current = true;
42 | };
43 |
44 | window.addEventListener("mousemove", updateActivity);
45 | window.addEventListener("keydown", updateActivity);
46 | window.addEventListener("scroll", updateActivity);
47 |
48 | return () => {
49 | window.removeEventListener("mousemove", updateActivity);
50 | window.removeEventListener("keydown", updateActivity);
51 | window.removeEventListener("scroll", updateActivity);
52 | };
53 | }, []); // empty deps -> attach once
54 |
55 | // Tauri focus/blur: update the ref only (don't set React state on blur).
56 | useEffect(() => {
57 | let unlistenFocus: (() => void) | null = null;
58 | let unlistenBlur: (() => void) | null = null;
59 |
60 | async function handleFocusEvents() {
61 | unlistenFocus = await listen("tauri://focus", () => {
62 | // When app regains focus, mark active and flush UI if reactive
63 | isActiveRef.current = true;
64 | lastActivityRef.current = Date.now();
65 | if (reactive) {
66 | // flush accumulated minutes so UI reflects the current value
67 | setMinutesRead(minutesRef.current);
68 | }
69 | });
70 |
71 | unlistenBlur = await listen("tauri://blur", () => {
72 | // Mark inactive in the ref only — avoid setState here to prevent rerender
73 | isActiveRef.current = false;
74 | });
75 | }
76 |
77 | void handleFocusEvents();
78 |
79 | return () => {
80 | if (unlistenFocus) unlistenFocus();
81 | if (unlistenBlur) unlistenBlur();
82 | };
83 | }, [reactive]); // only matters if reactive flag changes
84 |
85 | // Visibility change: when user returns, flush minutes to UI if reactive
86 | useEffect(() => {
87 | function onVisibilityChange() {
88 | if (typeof document === "undefined") return;
89 | if (!document.hidden && reactive) {
90 | // flush accumulated minutes so UI updates once on return
91 | setMinutesRead(minutesRef.current);
92 | }
93 | }
94 | if (typeof document !== "undefined") {
95 | document.addEventListener("visibilitychange", onVisibilityChange);
96 | }
97 | return () => {
98 | if (typeof document !== "undefined") {
99 | document.removeEventListener("visibilitychange", onVisibilityChange);
100 | }
101 | };
102 | }, [reactive]);
103 |
104 | // Interval: run once and rely on refs to determine behavior.
105 | useEffect(() => {
106 | intervalRef.current = window.setInterval(async () => {
107 | const now = Date.now();
108 | const inactiveTooLong = now - lastActivityRef.current > INACTIVITY_LIMIT;
109 | if (!isActiveRef.current || inactiveTooLong) return;
110 |
111 | // increment the internal counter (no rerender)
112 | minutesRef.current += 1;
113 |
114 | // Only update React state when reactive and visible so switching windows doesn't trigger UI updates.
115 | if (reactive && (typeof document === "undefined" || !document.hidden)) {
116 | setMinutesRead(minutesRef.current);
117 | }
118 |
119 | // Persist increment to DB (still awaited, unchanged)
120 | try {
121 | await updateStats(1); // +1 minute
122 | } catch (err) {
123 | // swallow/log; avoid setState changes from DB errors here to keep renders stable
124 | // console.error("updateStats error", err);
125 | }
126 | }, 60 * 1000);
127 |
128 | return () => {
129 | if (intervalRef.current) clearInterval(intervalRef.current);
130 | };
131 | }, [bookId, reactive]); // recreate interval if bookId or reactive mode changes
132 |
133 | async function updateStats(minutesIncrement: number) {
134 | const day = format(new Date(), "yyyy-MM-dd");
135 |
136 | await updateBookStats(minutesIncrement, bookId, day);
137 | await updateDailyUserStats(minutesIncrement, day);
138 | await incrementMinutesReadForBook(bookId);
139 | }
140 |
141 | // Return minutesRead (reactive if requested) and isActive (non-reactive snapshot).
142 | return {
143 | // If reactive is true we give the stateful value; otherwise return the ref value (non-reactive).
144 | minutesRead: reactive ? minutesRead : minutesRef.current,
145 | isActive: isActiveRef.current,
146 | };
147 | }
148 |
--------------------------------------------------------------------------------
/src-tauri/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "6",
3 | "dialect": "sqlite",
4 | "id": "bfbdb42e-e7b0-46be-ac82-eb5b63c4047e",
5 | "prevId": "00000000-0000-0000-0000-000000000000",
6 | "tables": {
7 | "books": {
8 | "name": "books",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "integer",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": true
16 | },
17 | "book_id": {
18 | "name": "book_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true,
22 | "autoincrement": false
23 | },
24 | "title": {
25 | "name": "title",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "author": {
32 | "name": "author",
33 | "type": "text",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "autoincrement": false
37 | }
38 | },
39 | "indexes": {
40 | "books_book_id_unique": {
41 | "name": "books_book_id_unique",
42 | "columns": [
43 | "book_id"
44 | ],
45 | "isUnique": true
46 | }
47 | },
48 | "foreignKeys": {},
49 | "compositePrimaryKeys": {},
50 | "uniqueConstraints": {},
51 | "checkConstraints": {}
52 | },
53 | "daily_book_stats": {
54 | "name": "daily_book_stats",
55 | "columns": {
56 | "id": {
57 | "name": "id",
58 | "type": "integer",
59 | "primaryKey": true,
60 | "notNull": true,
61 | "autoincrement": true
62 | },
63 | "day": {
64 | "name": "day",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true,
68 | "autoincrement": false
69 | },
70 | "book_id": {
71 | "name": "book_id",
72 | "type": "text",
73 | "primaryKey": false,
74 | "notNull": true,
75 | "autoincrement": false
76 | },
77 | "minutes_read": {
78 | "name": "minutes_read",
79 | "type": "integer",
80 | "primaryKey": false,
81 | "notNull": true,
82 | "autoincrement": false,
83 | "default": 0
84 | },
85 | "last_active": {
86 | "name": "last_active",
87 | "type": "integer",
88 | "primaryKey": false,
89 | "notNull": false,
90 | "autoincrement": false
91 | }
92 | },
93 | "indexes": {},
94 | "foreignKeys": {
95 | "daily_book_stats_book_id_books_book_id_fk": {
96 | "name": "daily_book_stats_book_id_books_book_id_fk",
97 | "tableFrom": "daily_book_stats",
98 | "tableTo": "books",
99 | "columnsFrom": [
100 | "book_id"
101 | ],
102 | "columnsTo": [
103 | "book_id"
104 | ],
105 | "onDelete": "no action",
106 | "onUpdate": "no action"
107 | }
108 | },
109 | "compositePrimaryKeys": {},
110 | "uniqueConstraints": {},
111 | "checkConstraints": {}
112 | },
113 | "daily_user_stats": {
114 | "name": "daily_user_stats",
115 | "columns": {
116 | "id": {
117 | "name": "id",
118 | "type": "integer",
119 | "primaryKey": true,
120 | "notNull": true,
121 | "autoincrement": true
122 | },
123 | "day": {
124 | "name": "day",
125 | "type": "text",
126 | "primaryKey": false,
127 | "notNull": true,
128 | "autoincrement": false
129 | },
130 | "minutes_read": {
131 | "name": "minutes_read",
132 | "type": "integer",
133 | "primaryKey": false,
134 | "notNull": true,
135 | "autoincrement": false,
136 | "default": 0
137 | },
138 | "sessions_count": {
139 | "name": "sessions_count",
140 | "type": "integer",
141 | "primaryKey": false,
142 | "notNull": true,
143 | "autoincrement": false,
144 | "default": 0
145 | }
146 | },
147 | "indexes": {},
148 | "foreignKeys": {},
149 | "compositePrimaryKeys": {},
150 | "uniqueConstraints": {},
151 | "checkConstraints": {}
152 | },
153 | "daily_reading_goal": {
154 | "name": "daily_reading_goal",
155 | "columns": {
156 | "id": {
157 | "name": "id",
158 | "type": "integer",
159 | "primaryKey": true,
160 | "notNull": true,
161 | "autoincrement": true
162 | },
163 | "minutes_to_read": {
164 | "name": "minutes_to_read",
165 | "type": "integer",
166 | "primaryKey": false,
167 | "notNull": true,
168 | "autoincrement": false
169 | },
170 | "associated_book": {
171 | "name": "associated_book",
172 | "type": "text",
173 | "primaryKey": false,
174 | "notNull": false,
175 | "autoincrement": false
176 | },
177 | "start_date": {
178 | "name": "start_date",
179 | "type": "text",
180 | "primaryKey": false,
181 | "notNull": true,
182 | "autoincrement": false
183 | },
184 | "end_date": {
185 | "name": "end_date",
186 | "type": "text",
187 | "primaryKey": false,
188 | "notNull": false,
189 | "autoincrement": false
190 | }
191 | },
192 | "indexes": {},
193 | "foreignKeys": {
194 | "daily_reading_goal_associated_book_books_book_id_fk": {
195 | "name": "daily_reading_goal_associated_book_books_book_id_fk",
196 | "tableFrom": "daily_reading_goal",
197 | "tableTo": "books",
198 | "columnsFrom": [
199 | "associated_book"
200 | ],
201 | "columnsTo": [
202 | "book_id"
203 | ],
204 | "onDelete": "cascade",
205 | "onUpdate": "no action"
206 | }
207 | },
208 | "compositePrimaryKeys": {},
209 | "uniqueConstraints": {},
210 | "checkConstraints": {}
211 | }
212 | },
213 | "views": {},
214 | "enums": {},
215 | "_meta": {
216 | "schemas": {},
217 | "tables": {},
218 | "columns": {}
219 | },
220 | "internal": {
221 | "indexes": {}
222 | }
223 | }
--------------------------------------------------------------------------------
/src/db/services/stats.services.ts:
--------------------------------------------------------------------------------
1 | import { eq, and, gt, lte } from "drizzle-orm";
2 | import { dailyBookStats, dailyUserStats } from "@/db/schema/stats";
3 | import { db } from "..";
4 | import { differenceInCalendarDays, format, parseISO, subDays } from "date-fns";
5 | import { gte } from "drizzle-orm";
6 |
7 | export async function updateBookStats(
8 | minutesIncrement: number,
9 | bookId: string,
10 | day: string
11 | ) {
12 | const existing = await db.query.dailyBookStats.findFirst({
13 | where: and(eq(dailyBookStats.bookId, bookId), eq(dailyBookStats.day, day)),
14 | });
15 |
16 | if (existing) {
17 | await db
18 | .update(dailyBookStats)
19 | .set({
20 | minutesRead: existing.minutesRead + minutesIncrement,
21 | lastActive: Date.now(),
22 | })
23 | .where(
24 | and(eq(dailyBookStats.bookId, bookId), eq(dailyBookStats.day, day))
25 | );
26 | } else {
27 | // Insert new record
28 | await db.insert(dailyBookStats).values({
29 | bookId,
30 | day,
31 | minutesRead: minutesIncrement,
32 | lastActive: Date.now(),
33 | });
34 | }
35 |
36 | console.log(`[DB] Updated ${bookId} for ${day} (+${minutesIncrement} min)`);
37 | }
38 |
39 | export async function updateDailyUserStats(
40 | minutesIncrement: number,
41 | day: string
42 | ) {
43 | // Check if there's already a record for today
44 | const existing = await db.query.dailyUserStats.findFirst({
45 | where: eq(dailyUserStats.day, day),
46 | });
47 |
48 | if (existing) {
49 | // Update today's total minutes and session count
50 | await db
51 | .update(dailyUserStats)
52 | .set({
53 | minutesRead: existing.minutesRead + minutesIncrement,
54 | })
55 | .where(eq(dailyUserStats.day, day));
56 | } else {
57 | // Insert a new record for today
58 | await db.insert(dailyUserStats).values({
59 | day,
60 | minutesRead: minutesIncrement,
61 | });
62 | }
63 |
64 | console.log(`[DB] Updated daily stats for ${day} (+${minutesIncrement} min)`);
65 | }
66 |
67 | export async function getDailyUserStatsForLastDays(days: number) {
68 | const today = new Date();
69 | const startDate = subDays(today, days - 1);
70 | const formattedStartDate = format(startDate, "yyyy-MM-dd");
71 |
72 | const stats = await db
73 | .select()
74 | .from(dailyUserStats)
75 | .where(gte(dailyUserStats.day, formattedStartDate))
76 | .orderBy(dailyUserStats.day);
77 |
78 | return stats;
79 | }
80 |
81 | export async function seedRandomDailyUserStats() {
82 | const today = new Date();
83 |
84 | const last7Days = Array.from({ length: 7 }, (_, i) =>
85 | format(subDays(today, i), "yyyy-MM-dd")
86 | );
87 |
88 | const randomDays = last7Days.sort(() => Math.random() - 0.5).slice(0, 4);
89 |
90 | const entries = randomDays.map((day) => ({
91 | day,
92 | minutesRead: Math.floor(Math.random() * 120) + 1, // 1–120 min
93 | }));
94 |
95 | await db.insert(dailyUserStats).values(entries);
96 | }
97 |
98 | export async function getStreakSummary() {
99 | // Fetch all reading days (with > 0 minutes)
100 | const stats = await db
101 | .select()
102 | .from(dailyUserStats)
103 | .where(gt(dailyUserStats.minutesRead, 0))
104 | .orderBy(dailyUserStats.day); // ascending
105 |
106 | if (stats.length === 0) {
107 | return {
108 | currentStreak: 0,
109 | longestStreak: 0,
110 | totalDays: 0,
111 | };
112 | }
113 |
114 | // Build a sorted array of unique Date objects (dedupe same dates)
115 | const seen = new Set();
116 | const uniqueDates: Date[] = [];
117 | for (const s of stats) {
118 | // s.day already in 'yyyy-MM-dd' format
119 | if (!seen.has(s.day)) {
120 | seen.add(s.day);
121 | uniqueDates.push(parseISO(s.day));
122 | }
123 | }
124 |
125 | const totalDays = uniqueDates.length;
126 |
127 | if (totalDays === 0) {
128 | return {
129 | currentStreak: 0,
130 | longestStreak: 0,
131 | totalDays: 0,
132 | };
133 | }
134 |
135 | // Compute longest streak
136 | let longestStreak = 1;
137 | let tempStreak = 1;
138 |
139 | for (let i = 1; i < uniqueDates.length; i++) {
140 | const diff = differenceInCalendarDays(uniqueDates[i], uniqueDates[i - 1]);
141 |
142 | if (diff === 1) {
143 | tempStreak++;
144 | longestStreak = Math.max(longestStreak, tempStreak);
145 | } else if (diff === 0) {
146 | // same day (shouldn't happen after dedupe, but safe to ignore)
147 | continue;
148 | } else {
149 | tempStreak = 1;
150 | }
151 | }
152 |
153 | // Compute current streak
154 | let currentStreak = 0;
155 | const lastReading = uniqueDates[uniqueDates.length - 1];
156 | const today = new Date();
157 | const diffToToday = differenceInCalendarDays(today, lastReading);
158 |
159 | // only start counting if lastReading is today
160 | if (diffToToday === 0) {
161 | currentStreak = 1; // include lastReading
162 | for (let i = uniqueDates.length - 1; i > 0; i--) {
163 | const diff = differenceInCalendarDays(uniqueDates[i], uniqueDates[i - 1]);
164 |
165 | if (diff === 1) {
166 | currentStreak++;
167 | } else if (diff === 0) {
168 | // ignore duplicates (defensive)
169 | continue;
170 | } else {
171 | break;
172 | }
173 | }
174 | }
175 |
176 | return {
177 | currentStreak,
178 | longestStreak,
179 | totalDays,
180 | };
181 | }
182 | export async function getBooksForDay(day: string) {
183 | const results = await db.query.dailyBookStats.findMany({
184 | where: eq(dailyBookStats.day, day),
185 | with: {
186 | book: true,
187 | },
188 | });
189 |
190 | return results;
191 | }
192 |
193 | export async function getLastYearUserStats() {
194 | const today = new Date();
195 | const startDate = subDays(today, 364);
196 |
197 | const start = format(startDate, "yyyy-MM-dd");
198 | const end = format(today, "yyyy-MM-dd");
199 |
200 | const stats = await db
201 | .select()
202 | .from(dailyUserStats)
203 | .where(and(gte(dailyUserStats.day, start), lte(dailyUserStats.day, end)))
204 | .orderBy(dailyUserStats.day);
205 |
206 | return stats;
207 | }
208 |
209 | export async function getBookReadDaysLastYear(bookId: string) {
210 | // Calculate the date 1 year ago
211 | const oneYearAgo = subDays(new Date(), 365);
212 | const formattedDate = format(oneYearAgo, "yyyy-MM-dd");
213 |
214 | // Query the dailyBookStats table
215 | const days = await db
216 | .select()
217 | .from(dailyBookStats)
218 | .where(
219 | and(
220 | eq(dailyBookStats.bookId, bookId),
221 | gte(dailyBookStats.day, formattedDate)
222 | )
223 | )
224 | .orderBy(dailyBookStats.day);
225 |
226 | return days;
227 | }
228 |
--------------------------------------------------------------------------------
/src-tauri/migrations/meta/0001_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "6",
3 | "dialect": "sqlite",
4 | "id": "3bc51208-4ff7-43e9-9c10-5a48fd57e62a",
5 | "prevId": "bfbdb42e-e7b0-46be-ac82-eb5b63c4047e",
6 | "tables": {
7 | "books": {
8 | "name": "books",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "integer",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": true
16 | },
17 | "book_id": {
18 | "name": "book_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true,
22 | "autoincrement": false
23 | },
24 | "title": {
25 | "name": "title",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "author": {
32 | "name": "author",
33 | "type": "text",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "autoincrement": false
37 | },
38 | "cover_image_path": {
39 | "name": "cover_image_path",
40 | "type": "text",
41 | "primaryKey": false,
42 | "notNull": false,
43 | "autoincrement": false
44 | }
45 | },
46 | "indexes": {
47 | "books_book_id_unique": {
48 | "name": "books_book_id_unique",
49 | "columns": [
50 | "book_id"
51 | ],
52 | "isUnique": true
53 | }
54 | },
55 | "foreignKeys": {},
56 | "compositePrimaryKeys": {},
57 | "uniqueConstraints": {},
58 | "checkConstraints": {}
59 | },
60 | "daily_book_stats": {
61 | "name": "daily_book_stats",
62 | "columns": {
63 | "id": {
64 | "name": "id",
65 | "type": "integer",
66 | "primaryKey": true,
67 | "notNull": true,
68 | "autoincrement": true
69 | },
70 | "day": {
71 | "name": "day",
72 | "type": "text",
73 | "primaryKey": false,
74 | "notNull": true,
75 | "autoincrement": false
76 | },
77 | "book_id": {
78 | "name": "book_id",
79 | "type": "text",
80 | "primaryKey": false,
81 | "notNull": true,
82 | "autoincrement": false
83 | },
84 | "minutes_read": {
85 | "name": "minutes_read",
86 | "type": "integer",
87 | "primaryKey": false,
88 | "notNull": true,
89 | "autoincrement": false,
90 | "default": 0
91 | },
92 | "last_active": {
93 | "name": "last_active",
94 | "type": "integer",
95 | "primaryKey": false,
96 | "notNull": false,
97 | "autoincrement": false
98 | }
99 | },
100 | "indexes": {},
101 | "foreignKeys": {
102 | "daily_book_stats_book_id_books_book_id_fk": {
103 | "name": "daily_book_stats_book_id_books_book_id_fk",
104 | "tableFrom": "daily_book_stats",
105 | "tableTo": "books",
106 | "columnsFrom": [
107 | "book_id"
108 | ],
109 | "columnsTo": [
110 | "book_id"
111 | ],
112 | "onDelete": "no action",
113 | "onUpdate": "no action"
114 | }
115 | },
116 | "compositePrimaryKeys": {},
117 | "uniqueConstraints": {},
118 | "checkConstraints": {}
119 | },
120 | "daily_user_stats": {
121 | "name": "daily_user_stats",
122 | "columns": {
123 | "id": {
124 | "name": "id",
125 | "type": "integer",
126 | "primaryKey": true,
127 | "notNull": true,
128 | "autoincrement": true
129 | },
130 | "day": {
131 | "name": "day",
132 | "type": "text",
133 | "primaryKey": false,
134 | "notNull": true,
135 | "autoincrement": false
136 | },
137 | "minutes_read": {
138 | "name": "minutes_read",
139 | "type": "integer",
140 | "primaryKey": false,
141 | "notNull": true,
142 | "autoincrement": false,
143 | "default": 0
144 | },
145 | "sessions_count": {
146 | "name": "sessions_count",
147 | "type": "integer",
148 | "primaryKey": false,
149 | "notNull": true,
150 | "autoincrement": false,
151 | "default": 0
152 | }
153 | },
154 | "indexes": {},
155 | "foreignKeys": {},
156 | "compositePrimaryKeys": {},
157 | "uniqueConstraints": {},
158 | "checkConstraints": {}
159 | },
160 | "daily_reading_goal": {
161 | "name": "daily_reading_goal",
162 | "columns": {
163 | "id": {
164 | "name": "id",
165 | "type": "integer",
166 | "primaryKey": true,
167 | "notNull": true,
168 | "autoincrement": true
169 | },
170 | "minutes_to_read": {
171 | "name": "minutes_to_read",
172 | "type": "integer",
173 | "primaryKey": false,
174 | "notNull": true,
175 | "autoincrement": false
176 | },
177 | "associated_book": {
178 | "name": "associated_book",
179 | "type": "text",
180 | "primaryKey": false,
181 | "notNull": false,
182 | "autoincrement": false
183 | },
184 | "start_date": {
185 | "name": "start_date",
186 | "type": "text",
187 | "primaryKey": false,
188 | "notNull": true,
189 | "autoincrement": false
190 | },
191 | "end_date": {
192 | "name": "end_date",
193 | "type": "text",
194 | "primaryKey": false,
195 | "notNull": false,
196 | "autoincrement": false
197 | }
198 | },
199 | "indexes": {},
200 | "foreignKeys": {
201 | "daily_reading_goal_associated_book_books_book_id_fk": {
202 | "name": "daily_reading_goal_associated_book_books_book_id_fk",
203 | "tableFrom": "daily_reading_goal",
204 | "tableTo": "books",
205 | "columnsFrom": [
206 | "associated_book"
207 | ],
208 | "columnsTo": [
209 | "book_id"
210 | ],
211 | "onDelete": "cascade",
212 | "onUpdate": "no action"
213 | }
214 | },
215 | "compositePrimaryKeys": {},
216 | "uniqueConstraints": {},
217 | "checkConstraints": {}
218 | }
219 | },
220 | "views": {},
221 | "enums": {},
222 | "_meta": {
223 | "schemas": {},
224 | "tables": {},
225 | "columns": {}
226 | },
227 | "internal": {
228 | "indexes": {}
229 | }
230 | }
--------------------------------------------------------------------------------
/src/components/dialogs/SettingsDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import { Button } from "@/components/ui/button";
11 | import { Label } from "@/components/ui/label";
12 | import {
13 | Select,
14 | SelectContent,
15 | SelectItem,
16 | SelectTrigger,
17 | SelectValue,
18 | } from "@/components/ui/select";
19 | import type { AlignmentOptions, FontFamilyOptions } from "@/lib/types/settings";
20 | import useSettingsStore from "@/stores/useSettingsStore";
21 | import { useReaderStore } from "@/stores/useReaderStore";
22 | import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGNMENT } from "@/lib/constants";
23 |
24 | const ALIGNMENT_OPTIONS: { value: AlignmentOptions; label: string }[] = [
25 | { value: "left", label: "Left" },
26 | { value: "center", label: "Center" },
27 | { value: "justify", label: "Justify" },
28 | ];
29 |
30 | const FONT_FAMILY_OPTIONS: { value: FontFamilyOptions; label: string }[] = [
31 | { value: "Helvetica", label: "Helvetica" },
32 | { value: "Lexend", label: "Lexend" },
33 | { value: "SegoeUI", label: "Segoe UI" },
34 | { value: "Robot", label: "Robot" },
35 | { value: "RobotoCondensed", label: "Roboto Condensed" },
36 | { value: "Comfortaa", label: "Comfortaa" },
37 | ];
38 |
39 | export function SettingsDialog() {
40 | const { settings, updateSetting } = useSettingsStore();
41 | const { isSettingsDialogOpen, toggleSettingsDialog } = useReaderStore(
42 | (store) => store
43 | );
44 | const [localSettings, setLocalSettings] = useState({
45 | textAlignment: settings?.textAlignment ?? DEFAULT_TEXT_ALIGNMENT,
46 | fontFamily: settings?.fontFamily ?? DEFAULT_FONT_FAMILY,
47 | });
48 |
49 | useEffect(() => {
50 | if (settings) {
51 | setLocalSettings({
52 | textAlignment: settings.textAlignment,
53 | fontFamily: settings.fontFamily,
54 | });
55 | }
56 | }, [settings]);
57 |
58 | const handleSave = async () => {
59 | await updateSetting("textAlignment", localSettings.textAlignment);
60 | await updateSetting("fontFamily", localSettings.fontFamily);
61 | };
62 |
63 | const handleReset = () => {
64 | if (settings) {
65 | setLocalSettings({
66 | textAlignment: settings.textAlignment,
67 | fontFamily: settings.fontFamily,
68 | });
69 | }
70 | };
71 |
72 | return (
73 |
183 | );
184 | }
185 |
--------------------------------------------------------------------------------
|