Nanahoshi provides a complete reading experience with an integrated EPUB reader, automatic progress tracking, and reading activity monitoring.
Reading progress tracking
Reading progress is stored per user and book, tracking position, status, and reading time:
packages/api/src/routers/reading-progress/reading-progress.model.ts
table: reading_progress {
userId: string;
bookId: bigint;
ttuBookId: number | null; // TTU reader book ID
exploredCharCount: number; // Characters read
bookCharCount: number; // Total characters in book
readingTimeSeconds: number; // Cumulative reading time
status: string; // "reading", "completed", "planning"
createdAt: Date;
updatedAt: Date;
}
Reading statuses
Books can be in different reading states:
packages/api/src/constants.ts
export const READING_STATUSES = {
PLANNING: "planning", // Want to read
READING: "reading", // Currently reading
COMPLETED: "completed", // Finished reading
ON_HOLD: "on_hold", // Paused
DROPPED: "dropped", // Stopped reading
};
Progress API
The reading progress service provides a simple interface:
packages/api/src/routers/reading-progress/reading-progress.service.ts
export const saveProgress = async (
userId: string,
bookUuid: string,
data: {
ttuBookId?: number;
exploredCharCount?: number;
bookCharCount?: number;
readingTimeSeconds?: number;
status?: string;
},
) => {
const bookRecord = await bookRepository.getByUuid(bookUuid);
if (!bookRecord) throw new ORPCError("NOT_FOUND", { message: "Book not found" });
const bookId = Number(bookRecord.id);
const existing = await readingProgressRepository.getByUserAndBook(userId, bookId);
const previousStatus = existing?.status;
const result = await readingProgressRepository.upsert(userId, bookId, data);
// Track status changes as activities
if (
data.status === READING_STATUSES.READING &&
previousStatus !== READING_STATUSES.READING
) {
await activityRepository.insert(
userId,
ACTIVITY_TYPES.STARTED_READING,
bookId,
);
}
if (
data.status === READING_STATUSES.COMPLETED &&
previousStatus !== READING_STATUSES.COMPLETED
) {
await activityRepository.insert(
userId,
ACTIVITY_TYPES.COMPLETED_READING,
bookId,
);
}
return result;
};
Retrieving progress
export const getProgress = async (userId: string, bookUuid: string) => {
const bookRecord = await bookRepository.getByUuid(bookUuid);
if (!bookRecord) throw new ORPCError("NOT_FOUND");
return readingProgressRepository.getByUserAndBook(
userId,
Number(bookRecord.id),
);
};
export const listInProgress = async (userId: string, limit = 20) => {
return readingProgressRepository.listInProgress(userId, limit);
};
Built-in EPUB reader
Nanahoshi includes a web-based EPUB reader built with React:
apps/web/src/components/book-reader/reader-iframe.tsx
export function ReaderIframe({ bookUuid }: { bookUuid: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// Sync reading progress
useReaderSync({
bookUuid,
iframeRef,
onProgressUpdate: (progress) => {
// Save progress to API
saveProgressMutation.mutate({
bookUuid,
exploredCharCount: progress.position,
bookCharCount: progress.total,
readingTimeSeconds: progress.timeSpent,
});
},
});
return (
<iframe
ref={iframeRef}
src={`/reader/${bookUuid}`}
className="w-full h-full"
/>
);
}
Reader synchronization
The reader hook manages bidirectional communication:
apps/web/src/components/book-reader/use-reader-sync.ts
export function useReaderSync({
bookUuid,
iframeRef,
onProgressUpdate,
}: ReaderSyncOptions) {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === "progress") {
onProgressUpdate(event.data.payload);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [onProgressUpdate]);
// Send initial position to reader
const { data: progress } = useQuery({
queryKey: ["reading-progress", bookUuid],
queryFn: () => orpc.readingProgress.get.query({ bookUuid }),
});
useEffect(() => {
if (progress && iframeRef.current) {
iframeRef.current.contentWindow?.postMessage(
{ type: "restore-position", payload: { position: progress.exploredCharCount } },
"*"
);
}
}, [progress, iframeRef]);
}
Reading activities
Important reading events are recorded as activities:
packages/api/src/constants.ts
export const ACTIVITY_TYPES = {
STARTED_READING: "started_reading",
COMPLETED_READING: "completed_reading",
LIKED_BOOK: "liked_book",
ADDED_TO_COLLECTION: "added_to_collection",
};
Activities create a timeline of user interactions:
table: activity {
id: number;
userId: string;
activityType: string; // One of ACTIVITY_TYPES
bookId: bigint | null;
createdAt: Date;
}
Activity tracking
packages/api/src/routers/profile/profile.repository.ts
export class ActivityRepository {
async insert(
userId: string,
activityType: string,
bookId?: number,
): Promise<void> {
await db.insert(activity).values({
userId,
activityType,
bookId: bookId ? BigInt(bookId) : null,
});
}
async listRecent(userId: string, limit = 50) {
return db
.select({
id: activity.id,
activityType: activity.activityType,
createdAt: activity.createdAt,
book: {
id: book.id,
uuid: book.uuid,
title: bookMetadata.title,
cover: bookMetadata.cover,
},
})
.from(activity)
.leftJoin(book, eq(book.id, activity.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, book.id))
.where(eq(activity.userId, userId))
.orderBy(desc(activity.createdAt))
.limit(limit);
}
}
Reading statistics
Progress data enables rich statistics:
Progress percentage
Calculate completion: (exploredCharCount / bookCharCount) * 100
Reading time
Track total time spent reading each book
Reading streak
Identify consecutive days with reading activity
Books completed
Count books with status: "completed"
Example statistics query
// Get reading statistics for a user
const stats = await db
.select({
totalBooks: sql<number>`COUNT(DISTINCT book_id)`,
booksCompleted: sql<number>`COUNT(DISTINCT book_id) FILTER (WHERE status = 'completed')`,
totalReadingTime: sql<number>`SUM(reading_time_seconds)`,
avgProgress: sql<number>`AVG(explored_char_count::float / NULLIF(book_char_count, 0))`,
})
.from(readingProgress)
.where(eq(readingProgress.userId, userId));
Bookmarks (future feature)
The schema supports bookmarks for marking interesting passages:
table: bookmark {
id: number;
userId: string;
bookId: bigint;
position: number; // Character offset or CFI
note: string | null; // Optional annotation
createdAt: Date;
}
Bookmark functionality is planned but not yet implemented in the current version.
Progress persistence
Reading progress is automatically saved:
- On position change - Debounced updates every 5 seconds
- On page navigation - Saved when moving between chapters
- On reader close - Final save when exiting the reader
- Status changes - Immediate save when marking as completed/on hold
Upsert pattern
The repository uses an upsert to handle both new and existing progress:
packages/api/src/routers/reading-progress/reading-progress.repository.ts
async upsert(
userId: string,
bookId: number,
data: Partial<ReadingProgress>,
) {
const [result] = await db
.insert(readingProgress)
.values({
userId,
bookId: BigInt(bookId),
...data,
})
.onConflictDoUpdate({
target: [readingProgress.userId, readingProgress.bookId],
set: {
...data,
updatedAt: new Date(),
},
})
.returning();
return result;
}
Currently reading list
Retrieve books currently being read:
packages/api/src/routers/reading-progress/reading-progress.repository.ts
async listInProgress(userId: string, limit = 20) {
return db
.select({
bookId: readingProgress.bookId,
exploredCharCount: readingProgress.exploredCharCount,
bookCharCount: readingProgress.bookCharCount,
readingTimeSeconds: readingProgress.readingTimeSeconds,
updatedAt: readingProgress.updatedAt,
book: {
uuid: book.uuid,
filename: book.filename,
title: bookMetadata.title,
cover: bookMetadata.cover,
},
})
.from(readingProgress)
.innerJoin(book, eq(book.id, readingProgress.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, book.id))
.where(
and(
eq(readingProgress.userId, userId),
eq(readingProgress.status, READING_STATUSES.READING),
),
)
.orderBy(desc(readingProgress.updatedAt))
.limit(limit);
}
Integration example
await orpc.readingProgress.save.mutate({
bookUuid: "abc-123",
exploredCharCount: 15420,
bookCharCount: 50000,
readingTimeSeconds: 3600,
status: "reading",
});