feat: US #32 Section-based Reading Recommendations

Backend:
- Add ReadingSchedule and MeetingSection models for ebook reading schedules
- Implement recommendation algorithm: greedy partition minimizing per-meeting time variance
- Algorithm respects chapter boundaries, estimates time based on chapter weight
- Add serializers for schedules (list/detail/generate/confirm/update)
- Add ScheduleViewSet with endpoints: list, create (generate), retrieve, regenerate, confirm, update_meeting, reorder_meetings
- Add URL routes for /api/books/schedules/
- Create migration 0004 for new models

Frontend:
- Add types: MeetingSection, ReadingSchedule, GenerateRecommendationsRequest
- Add schedulesApi with methods: getSchedules, generateRecommendations, confirmSchedule, updateMeeting, reorderMeetings, deleteSchedule
- Build RecommendationPage with 4-column meeting layout, drag-and-drop chapter reassignment, generate/regenerate/confirm flow
- Add 'Schedule Reading' menu item to BookContextMenu
- Add route: /books/:ebookId/schedule
This commit is contained in:
Marko
2026-06-20 19:31:39 +00:00
parent 26c5f6f06b
commit 4a7133465f
11 changed files with 1266 additions and 9 deletions
+2
View File
@@ -12,6 +12,7 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default:
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
const RecommendationPage = lazy(() => import("./pages/RecommendationPage").then((m) => ({ default: m.RecommendationPage })));
const AuthPage = lazy(() => import("./pages/AuthPage"));
@@ -40,6 +41,7 @@ function AppRoutes() {
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/books/:ebookId/schedule" element={<ProtectedRoute><RecommendationPage /></ProtectedRoute>} />
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
+63
View File
@@ -6,7 +6,9 @@ import type {
ContentResponse,
EBookDetail,
EBookListItem,
GenerateRecommendationsRequest,
ReadingProgress,
ReadingSchedule,
ReadingSettings,
TocResponse,
} from "../types/book";
@@ -131,4 +133,65 @@ export const booksApi = {
const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData);
return data;
},
};
export const schedulesApi = {
async getSchedules(): Promise<ReadingSchedule[]> {
const { data } = await api.get<ReadingSchedule[]>("/books/schedules/");
return data;
},
async getSchedule(id: number): Promise<ReadingSchedule> {
const { data } = await api.get<ReadingSchedule>(`/books/schedules/${id}/`);
return data;
},
async generateRecommendations(request: GenerateRecommendationsRequest): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>("/books/schedules/", {
ebook_id: request.ebook_id,
meeting_count: request.meeting_count ?? 4,
});
return data;
},
async regenerateRecommendations(scheduleId: number, meetingCount?: number): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/generate/`, {
meeting_count: meetingCount ?? 4,
});
return data;
},
async confirmSchedule(scheduleId: number): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/confirm/`, {
confirm: true,
});
return data;
},
async updateMeeting(
scheduleId: number,
meetingId: number,
update: { chapter_ids?: number[]; title?: string; meeting_index?: number },
): Promise<unknown> {
const { data } = await api.patch(
`/books/schedules/${scheduleId}/meetings/${meetingId}/`,
update,
);
return data;
},
async reorderMeetings(
scheduleId: number,
meetings: { id: number; meeting_index: number }[],
): Promise<ReadingSchedule> {
const { data } = await api.patch<ReadingSchedule>(
`/books/schedules/${scheduleId}/reorder/`,
{ meetings },
);
return data;
},
async deleteSchedule(id: number): Promise<void> {
await api.delete(`/books/schedules/${id}/`);
},
};
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
@@ -46,6 +47,7 @@ export function BookContextMenu({
const { t, language } = useTranslation();
const locale = language as SupportedLanguage;
const { showToast } = useToast();
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
const [activeAction, setActiveAction] = useState<ActionKind>(null);
@@ -148,6 +150,18 @@ export function BookContextMenu({
>
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
</button>
<button
type="button"
className={styles.item}
role="menuitem"
disabled={busy}
onClick={() => {
navigate(`/books/${book.id}/schedule`);
onClose();
}}
>
📅 Schedule Reading
</button>
<div className={styles.separator} role="separator" />
<button
type="button"
+601
View File
@@ -0,0 +1,601 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { schedulesApi } from "../api/books";
import { booksApi } from "../api/books";
import type {
BookChapter,
EBookListItem,
MeetingSection,
ReadingSchedule,
} from "../types/book";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
/* ---- drag-and-drop types ---- */
interface DragData {
chapterId: number;
sourceMeetingId: number | null; // null = unassigned
}
function formatTime(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
export function RecommendationPage() {
const { t } = useTranslation();
const { ebookId } = useParams<{ ebookId: string }>();
const navigate = useNavigate();
const isMobile = useMediaQuery(BREAKPOINTS.md);
const [ebook, setEbook] = useState<EBookListItem | null>(null);
const [schedule, setSchedule] = useState<ReadingSchedule | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draggedChapter, setDraggedChapter] = useState<DragData | null>(null);
const [dropTarget, setDropTarget] = useState<number | null>(null);
const bookId = Number(ebookId);
const validId = !Number.isNaN(bookId);
const loadData = useCallback(async () => {
if (!validId) {
setError("Invalid ebook ID.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [ebookData, schedules] = await Promise.all([
booksApi.getEBook(bookId),
schedulesApi.getSchedules(),
]);
setEbook({
id: ebookData.id,
title: ebookData.title,
author: ebookData.author,
filename: ebookData.filename,
format: ebookData.format,
page_count: ebookData.page_count,
cover_image:
typeof ebookData.cover_image === "string" ? ebookData.cover_image : null,
created_at: ebookData.created_at,
progress: ebookData.progress?.current_position ?? null,
started: ebookData.progress != null,
});
const existing = schedules.find((s) => s.ebook === bookId);
setSchedule(existing ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data.");
} finally {
setLoading(false);
}
}, [bookId, validId]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleGenerate = useCallback(async () => {
if (!validId) return;
setGenerating(true);
setError(null);
try {
const newSchedule = await schedulesApi.generateRecommendations({
ebook_id: bookId,
meeting_count: 4,
});
setSchedule(newSchedule);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate recommendations.");
} finally {
setGenerating(false);
}
}, [bookId, validId]);
const handleRegenerate = useCallback(async () => {
if (!schedule) return;
setGenerating(true);
setError(null);
try {
const updated = await schedulesApi.regenerateRecommendations(schedule.id);
setSchedule(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to regenerate recommendations.");
} finally {
setGenerating(false);
}
}, [schedule]);
const handleConfirm = useCallback(async () => {
if (!schedule) return;
setError(null);
try {
const updated = await schedulesApi.confirmSchedule(schedule.id);
setSchedule(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to confirm schedule.");
}
}, [schedule]);
/* ---- drag-and-drop handlers ---- */
const handleDragStart = useCallback(
(chapterId: number, sourceMeetingId: number | null) => (e: React.DragEvent) => {
setDraggedChapter({ chapterId, sourceMeetingId });
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(chapterId));
},
[],
);
const handleDragOver = useCallback(
(targetMeetingId: number) => (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTarget(targetMeetingId);
},
[],
);
const handleDragLeave = useCallback(() => {
setDropTarget(null);
}, []);
const handleDrop = useCallback(
async (targetMeetingId: number) => {
setDropTarget(null);
if (!draggedChapter || !schedule) return;
const { chapterId, sourceMeetingId } = draggedChapter;
setDraggedChapter(null);
// Same meeting — no-op
if (sourceMeetingId === targetMeetingId) return;
// Optimistically update UI
const updatedMeetings = schedule.meetings.map((m) => ({
...m,
chapter_ids: [...m.chapter_ids],
chapters: [...m.chapters],
}));
// Remove from source
if (sourceMeetingId !== null) {
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
if (source) {
source.chapter_ids = source.chapter_ids.filter((id) => id !== chapterId);
source.chapters = source.chapters.filter((ch) => ch.id !== chapterId);
}
}
// Add to target
const target = updatedMeetings.find((m) => m.id === targetMeetingId);
if (target && !target.chapter_ids.includes(chapterId)) {
target.chapter_ids = [...target.chapter_ids, chapterId];
// Find chapter data
const allChapters = schedule.meetings.flatMap((m) => m.chapters);
const movedChapter = allChapters.find((ch) => ch.id === chapterId);
if (movedChapter) {
target.chapters = [...target.chapters, movedChapter];
}
}
setSchedule({ ...schedule, meetings: updatedMeetings });
// Persist to backend
try {
await schedulesApi.updateMeeting(schedule.id, targetMeetingId, {
chapter_ids: target?.chapter_ids ?? [],
});
if (sourceMeetingId !== null) {
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
if (source) {
await schedulesApi.updateMeeting(schedule.id, sourceMeetingId, {
chapter_ids: source.chapter_ids,
});
}
}
} catch (err) {
setError("Failed to save changes. Please try again.");
// Reload to reset
void loadData();
}
},
[draggedChapter, schedule, loadData],
);
const handleUnassignDrop = useCallback(
async () => {
setDropTarget(null);
if (!draggedChapter || !schedule) return;
const { chapterId, sourceMeetingId } = draggedChapter;
setDraggedChapter(null);
if (sourceMeetingId === null) return;
// Remove from source
const updatedMeetings = schedule.meetings.map((m) => ({
...m,
chapter_ids: [...m.chapter_ids],
chapters: [...m.chapters],
}));
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
if (source) {
source.chapter_ids = source.chapter_ids.filter((id) => id !== chapterId);
source.chapters = source.chapters.filter((ch) => ch.id !== chapterId);
}
setSchedule({ ...schedule, meetings: updatedMeetings });
try {
await schedulesApi.updateMeeting(schedule.id, sourceMeetingId, {
chapter_ids: source?.chapter_ids ?? [],
});
} catch {
setError("Failed to save changes.");
void loadData();
}
},
[draggedChapter, schedule, loadData],
);
const handleDragEnd = useCallback(() => {
setDraggedChapter(null);
setDropTarget(null);
}, []);
/* ---- styles ---- */
const containerStyle: React.CSSProperties = {
maxWidth: 1200,
margin: "0 auto",
padding: isMobile ? 12 : 24,
minHeight: "100vh",
background: "#f8f9fa",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 24,
flexWrap: "wrap",
};
const backBtnStyle: React.CSSProperties = {
padding: "8px 16px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#374151",
fontSize: 14,
cursor: "pointer",
};
const btnPrimaryStyle: React.CSSProperties = {
padding: "10px 24px",
borderRadius: 8,
border: "none",
background: "#4f46e5",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
opacity: generating ? 0.7 : 1,
minHeight: 44,
};
const confirmBtnStyle: React.CSSProperties = {
...btnPrimaryStyle,
background: "#16a34a",
};
const meetingsGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: isMobile
? "1fr"
: `repeat(${schedule?.meeting_count ?? 4}, 1fr)`,
gap: 16,
marginTop: 16,
};
const meetingCardStyle = (isDropTarget: boolean): React.CSSProperties => ({
background: isDropTarget ? "#eef2ff" : "#fff",
borderRadius: 12,
border: `2px solid ${isDropTarget ? "#4f46e5" : "#e5e7eb"}`,
padding: 16,
minHeight: 200,
transition: "border-color 0.2s, background 0.2s",
});
const chapterChipStyle: React.CSSProperties = {
background: "#f3f4f6",
borderRadius: 8,
padding: "8px 12px",
marginBottom: 8,
cursor: "grab",
fontSize: 13,
color: "#374151",
border: "1px solid #e5e7eb",
userSelect: "none" as const,
};
/* ---- render ---- */
if (!validId) {
return (
<div style={containerStyle}>
<p style={{ color: "#ef4444" }}>Invalid ebook ID.</p>
</div>
);
}
if (loading) {
return (
<div style={containerStyle}>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: 400 }}>
<p style={{ color: "#888" }}>{t("common.loading")}</p>
</div>
</div>
);
}
if (error) {
return (
<div style={containerStyle}>
<button onClick={() => navigate(-1)} style={backBtnStyle}> Back</button>
<div style={{ textAlign: "center", padding: 60 }}>
<p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>
<button onClick={() => void loadData()} style={btnPrimaryStyle}>
{t("common.retry")}
</button>
</div>
</div>
);
}
return (
<div style={containerStyle}>
{/* Header */}
<div style={headerStyle}>
<button onClick={() => navigate("/")} style={backBtnStyle}>
{isMobile ? "Library" : "Back to Library"}
</button>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: isMobile ? 18 : 24, margin: 0, color: "#1f2937" }}>
Reading Schedule
</h1>
{ebook && (
<p style={{ color: "#6b7280", fontSize: 14, margin: "4px 0 0" }}>
{ebook.title}{ebook.author ? `${ebook.author}` : ""}
</p>
)}
</div>
{!schedule && (
<button onClick={handleGenerate} style={btnPrimaryStyle} disabled={generating}>
{generating ? "Generating..." : "Generate Recommendations"}
</button>
)}
{schedule && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button onClick={handleRegenerate} style={btnPrimaryStyle} disabled={generating}>
{generating ? "Regenerating..." : "Regenerate"}
</button>
{!schedule.confirmed && (
<button onClick={handleConfirm} style={confirmBtnStyle}>
Confirm Schedule
</button>
)}
</div>
)}
</div>
{/* Status badge */}
{schedule && (
<div style={{ marginBottom: 16 }}>
<span style={{
padding: "4px 12px",
borderRadius: 999,
fontSize: 13,
fontWeight: 600,
background: schedule.confirmed ? "#dcfce7" : "#fef3c7",
color: schedule.confirmed ? "#16a34a" : "#b45309",
}}>
{schedule.confirmed ? "✓ Confirmed" : "Draft — drag chapters to adjust"}
</span>
</div>
)}
{/* No schedule yet */}
{!schedule && (
<div style={{ textAlign: "center", padding: "60px 16px" }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>📅</div>
<h2 style={{ fontSize: 20, color: "#1f2937", marginBottom: 8 }}>
No Schedule Yet
</h2>
<p style={{ color: "#6b7280", marginBottom: 24, maxWidth: 400, margin: "0 auto 24px" }}>
Generate a reading schedule to split this book into balanced weekly assignments for your group.
</p>
<button onClick={handleGenerate} style={btnPrimaryStyle} disabled={generating}>
{generating ? "Generating..." : "Generate Recommendations"}
</button>
</div>
)}
{/* Schedule meetings */}
{schedule && (
<>
{/* Unassigned drop zone */}
<div
style={{
padding: "12px 16px",
background: dropTarget === null && draggedChapter ? "#fef2f2" : "#f9fafb",
borderRadius: 8,
border: `2px dashed ${dropTarget === null && draggedChapter ? "#ef4444" : "#d1d5db"}`,
marginBottom: 16,
textAlign: "center",
color: "#6b7280",
fontSize: 13,
minHeight: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s",
}}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDropTarget(null); }}
onDrop={handleUnassignDrop}
onDragLeave={() => setDropTarget(null)}
>
{dropTarget === null && draggedChapter
? "Drop here to unassign"
: "Drag chapters here to unassign from meetings"}
</div>
{/* Meetings grid */}
<div style={meetingsGridStyle}>
{schedule.meetings.map((meeting, idx) => (
<MeetingColumn
key={meeting.id}
meeting={meeting}
index={idx}
isDropTarget={dropTarget === meeting.id}
isConfirmed={schedule.confirmed}
onDragStart={handleDragStart}
onDragOver={handleDragOver(meeting.id)}
onDragLeave={handleDragLeave}
onDrop={() => void handleDrop(meeting.id)}
onDragEnd={handleDragEnd}
/>
))}
</div>
</>
)}
{/* Drag overlay instruction */}
{draggedChapter && (
<div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#1f2937",
color: "#fff",
padding: "12px 24px",
borderRadius: 12,
fontSize: 14,
boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
zIndex: 999,
}}>
Drop the chapter into a meeting column
</div>
)}
</div>
);
}
/* ---- Meeting Column Sub-component ---- */
interface MeetingColumnProps {
meeting: MeetingSection;
index: number;
isDropTarget: boolean;
isConfirmed: boolean;
onDragStart: (chapterId: number, sourceMeetingId: number | null) => (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: () => void;
onDragEnd: () => void;
}
function MeetingColumn({
meeting,
index,
isDropTarget,
isConfirmed,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
}: MeetingColumnProps) {
const chapterCount = meeting.chapter_ids.length;
const cardStyle: React.CSSProperties = {
background: isDropTarget ? "#eef2ff" : "#fff",
borderRadius: 12,
border: `2px solid ${isDropTarget ? "#4f46e5" : "#e5e7eb"}`,
overflow: "hidden",
transition: "border-color 0.2s, background 0.2s",
};
const headerStyle: React.CSSProperties = {
padding: "12px 16px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
};
const bodyStyle: React.CSSProperties = {
padding: "12px 16px",
minHeight: 120,
};
return (
<div style={cardStyle}>
<div style={headerStyle}>
<div style={{ fontWeight: 700, color: "#1f2937", fontSize: 15, marginBottom: 4 }}>
Week {index + 1}
</div>
<div style={{ display: "flex", gap: 16, fontSize: 12, color: "#6b7280" }}>
<span>{chapterCount} {chapterCount === 1 ? "chapter" : "chapters"}</span>
<span>{formatTime(meeting.estimated_time_minutes)}</span>
</div>
</div>
<div
style={bodyStyle}
onDragOver={isConfirmed ? undefined : onDragOver}
onDragLeave={isConfirmed ? undefined : onDragLeave}
onDrop={isConfirmed ? undefined : onDrop}
>
{meeting.chapters.length === 0 && (
<div style={{
color: "#9ca3af",
fontSize: 13,
textAlign: "center",
padding: "20px 0",
fontStyle: "italic",
}}>
{isDropTarget ? "Drop here" : "No chapters assigned"}
</div>
)}
{meeting.chapters.map((chapter) => (
<div
key={`${meeting.id}-${chapter.id}`}
draggable={!isConfirmed}
onDragStart={isConfirmed ? undefined : onDragStart(chapter.id, meeting.id)}
onDragEnd={isConfirmed ? undefined : onDragEnd}
style={{
background: "#f3f4f6",
borderRadius: 8,
padding: "8px 12px",
marginBottom: 8,
cursor: isConfirmed ? "default" : "grab",
fontSize: 13,
color: "#374151",
border: "1px solid #e5e7eb",
userSelect: "none" as const,
opacity: isConfirmed ? 0.8 : 1,
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{chapter.title}</div>
{chapter.href && (
<div style={{ fontSize: 11, color: "#9ca3af" }}>{chapter.href}</div>
)}
</div>
))}
</div>
</div>
);
}
+24
View File
@@ -101,4 +101,28 @@ export interface BookSearchParams {
ordering?: string;
page?: number;
page_size?: number;
}
export interface MeetingSection {
id: number;
meeting_index: number;
title: string;
estimated_time_minutes: number;
chapter_ids: number[];
chapters: BookChapter[];
}
export interface ReadingSchedule {
id: number;
ebook: number;
meeting_count: number;
confirmed: boolean;
meetings: MeetingSection[];
created_at: string;
updated_at: string;
}
export interface GenerateRecommendationsRequest {
ebook_id: number;
meeting_count?: number;
}