From 4a7133465f1373d915ff7fe83680598e895aa112 Mon Sep 17 00:00:00 2001 From: Marko Date: Sat, 20 Jun 2026 19:31:39 +0000 Subject: [PATCH] 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 --- .../migrations/0004_add_reading_schedule.py | 52 ++ backend/apps/books/models.py | 45 +- backend/apps/books/serializers.py | 64 +- .../books/services/recommendation_engine.py | 229 +++++++ backend/apps/books/urls.py | 3 +- backend/apps/books/views.py | 178 +++++- frontend/src/App.tsx | 2 + frontend/src/api/books.ts | 63 ++ frontend/src/components/BookContextMenu.tsx | 14 + frontend/src/pages/RecommendationPage.tsx | 601 ++++++++++++++++++ frontend/src/types/book.ts | 24 + 11 files changed, 1266 insertions(+), 9 deletions(-) create mode 100644 backend/apps/books/migrations/0004_add_reading_schedule.py create mode 100644 backend/apps/books/services/recommendation_engine.py create mode 100644 frontend/src/pages/RecommendationPage.tsx diff --git a/backend/apps/books/migrations/0004_add_reading_schedule.py b/backend/apps/books/migrations/0004_add_reading_schedule.py new file mode 100644 index 0000000..e0bd2e6 --- /dev/null +++ b/backend/apps/books/migrations/0004_add_reading_schedule.py @@ -0,0 +1,52 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("books", "0003_readingprogress_epub_location"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ReadingSchedule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("meeting_count", models.PositiveIntegerField(default=4)), + ("confirmed", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("ebook", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to="books.ebook")), + ("user", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "books_reading_schedule", + "verbose_name": "Reading Schedule", + "verbose_name_plural": "Reading Schedules", + "ordering": ["-created_at"], + "unique_together": {("ebook", "user")}, + "indexes": [models.Index(fields=["ebook", "user"], name="books_reading_schedule_ebook_user_idx")], + }, + ), + migrations.CreateModel( + name="MeetingSection", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("meeting_index", models.PositiveIntegerField()), + ("title", models.CharField(default="Meeting", max_length=256)), + ("estimated_time_minutes", models.PositiveIntegerField(default=0)), + ("chapter_ids", models.JSONField(default=list)), + ("schedule", models.ForeignKey(on_delete=models.CASCADE, related_name="meetings", to="books.readingschedule")), + ], + options={ + "db_table": "books_meeting_section", + "verbose_name": "Meeting Section", + "verbose_name_plural": "Meeting Sections", + "ordering": ["meeting_index"], + "unique_together": {("schedule", "meeting_index")}, + "indexes": [models.Index(fields=["schedule", "meeting_index"], name="books_meeting_section_schedule_meeting_idx")], + }, + ), + ] \ No newline at end of file diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index ba93827..f11799e 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -153,4 +153,47 @@ class ReadingProgress(models.Model): "current_position", "last_page", "device_id", "device_name", "version", "updated_at", ]) - return self, True \ No newline at end of file + return self, True + + +class ReadingSchedule(models.Model): + """A reading schedule that groups sections into weekly meetings for an ebook.""" + + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="schedules") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="schedules") + meeting_count = models.PositiveIntegerField(default=4) + confirmed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_reading_schedule" + verbose_name = "Reading Schedule" + verbose_name_plural = "Reading Schedules" + ordering = ["-created_at"] + unique_together = [("ebook", "user")] + indexes = [models.Index(fields=["ebook", "user"])] + + def __str__(self): + return f"Schedule for {self.ebook.title} ({self.meeting_count} meetings)" + + +class MeetingSection(models.Model): + """A single meeting within a reading schedule, containing assigned chapters.""" + + schedule = models.ForeignKey(ReadingSchedule, on_delete=models.CASCADE, related_name="meetings") + meeting_index = models.PositiveIntegerField() + title = models.CharField(max_length=256, default="Meeting") + estimated_time_minutes = models.PositiveIntegerField(default=0) + chapter_ids = models.JSONField(default=list) + + class Meta: + db_table = "books_meeting_section" + verbose_name = "Meeting Section" + verbose_name_plural = "Meeting Sections" + ordering = ["meeting_index"] + unique_together = [("schedule", "meeting_index")] + indexes = [models.Index(fields=["schedule", "meeting_index"])] + + def __str__(self): + return f"Meeting {self.meeting_index + 1}: {self.title}" \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index a5b4fe2..36a87eb 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers import logging -from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord +from apps.books.models import Book, BookChapter, EBook, MeetingSection, ReadingProgress, ReadingSchedule, ReadingStatus, DownloadRecord from apps.books.services.ebook_metadata import subjects_from_ebook from apps.books.services.metadata import enrich_ebook_metadata from apps.books.services.process_ebook import apply_processing_to_ebook @@ -228,4 +228,64 @@ class DownloadRecordSerializer(serializers.ModelSerializer): class StorageSummarySerializer(serializers.Serializer): total_downloads = serializers.IntegerField() total_size_bytes = serializers.IntegerField() - ebooks = serializers.ListField(child=serializers.DictField()) \ No newline at end of file + ebooks = serializers.ListField(child=serializers.DictField()) + + +class MeetingSectionSerializer(serializers.ModelSerializer): + chapters = serializers.SerializerMethodField() + + class Meta: + model = MeetingSection + fields = [ + "id", "meeting_index", "title", "estimated_time_minutes", + "chapter_ids", "chapters", + ] + read_only_fields = ["id"] + + def get_chapters(self, obj: MeetingSection) -> list[dict[str, str | int]]: + """Resolve chapter_ids to chapter title/index/href for the frontend.""" + if not obj.chapter_ids: + return [] + chapters_qs = BookChapter.objects.filter( + id__in=obj.chapter_ids, + ebook=obj.schedule.ebook, + ).order_by("index").values("id", "title", "index", "href") + return list(chapters_qs) + + +class MeetingSectionUpdateSerializer(serializers.Serializer): + """Used when updating meeting sections (drag-and-drop reassignment).""" + meeting_id = serializers.IntegerField(required=False) + meeting_index = serializers.IntegerField(required=False) + chapter_ids = serializers.ListField(child=serializers.IntegerField(), required=False) + title = serializers.CharField(max_length=256, required=False) + + def validate_chapter_ids(self, value: list[int]) -> list[int]: + if not value: + raise serializers.ValidationError("chapter_ids must not be empty.") + return value + + +class ReadingScheduleSerializer(serializers.ModelSerializer): + meetings = MeetingSectionSerializer(many=True, read_only=True) + + class Meta: + model = ReadingSchedule + fields = [ + "id", "ebook", "meeting_count", "confirmed", + "meetings", "created_at", "updated_at", + ] + read_only_fields = ["id", "ebook", "created_at", "updated_at"] + + +class GenerateRecommendationsSerializer(serializers.Serializer): + meeting_count = serializers.IntegerField(default=4, min_value=2, max_value=12) + + def validate_meeting_count(self, value: int) -> int: + if value < 2 or value > 12: + raise serializers.ValidationError("Meeting count must be between 2 and 12.") + return value + + +class ConfirmScheduleSerializer(serializers.Serializer): + confirm = serializers.BooleanField(default=True) \ No newline at end of file diff --git a/backend/apps/books/services/recommendation_engine.py b/backend/apps/books/services/recommendation_engine.py new file mode 100644 index 0000000..1d9feb9 --- /dev/null +++ b/backend/apps/books/services/recommendation_engine.py @@ -0,0 +1,229 @@ +"""Recommendation engine for splitting book chapters into balanced weekly meetings. + +Algorithm: + 1. Gather all BookChapters for an ebook (flat list, respecting hierarchy). + 2. For each chapter, estimate reading time based on content length or default weight. + 3. Partition chapters into N meetings (default 4) minimizing per-meeting time variance + while respecting chapter boundaries (never split a chapter). + 4. Optionally prefer natural boundaries: if a chapter's children exist, + keep siblings together where possible. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +from apps.books.models import BookChapter, EBook, MeetingSection, ReadingSchedule + +logger = logging.getLogger(__name__) + +DEFAULT_MEETING_COUNT = 4 + + +@dataclass +class ChapterWithWeight: + chapter_id: int + title: str + href: str + index: int + weight: float # estimated reading time weight + children: list[int] = field(default_factory=list) + + +def _collect_chapters(ebook: EBook) -> list[ChapterWithWeight]: + """Collect all chapters for an ebook as a flat, ordered list with weights.""" + chapters_qs = ( + BookChapter.objects.filter(ebook=ebook) + .order_by("index") + .prefetch_related("ebook") + ) + chapters = list(chapters_qs) + + # Build a map of chapter id -> ChapterWithWeight + result: list[ChapterWithWeight] = [] + for ch in chapters: + weight = _estimate_chapter_weight(ch) + result.append( + ChapterWithWeight( + chapter_id=ch.id, + title=ch.title, + href=ch.href or "", + index=ch.index, + weight=weight, + ) + ) + + return result + + +def _estimate_chapter_weight(chapter: BookChapter) -> float: + """Estimate reading time weight for a chapter. + + Defaults to 1.0 per chapter. Sub-chapters (those with non-empty children) + get a weight proportional to their child count + 1. + """ + children = chapter.children or [] + child_count = len(children) + if child_count > 0: + return float(child_count + 1) + return 1.0 + + +def _partition_chapters( + chapters: list[ChapterWithWeight], + num_meetings: int = DEFAULT_MEETING_COUNT, +) -> list[list[ChapterWithWeight]]: + """Partition chapters into `num_meetings` groups minimizing weight variance. + + Uses a greedy approach: compute target weight per meeting, then place + chapters sequentially, starting a new meeting when cumulative weight + exceeds the target and adding the overflow to the next meeting. + + Never splits a chapter — boundaries are always at chapter edges. + """ + if not chapters: + return [[] for _ in range(num_meetings)] + + total_weight = sum(ch.weight for ch in chapters) + target_weight = total_weight / num_meetings + + meetings: list[list[ChapterWithWeight]] = [] + current_meeting: list[ChapterWithWeight] = [] + current_weight = 0.0 + + for ch in chapters: + # If adding this chapter would overshoot target significantly + # and we have at least one chapter in current meeting, + # and there are more meetings to fill, start a new meeting + if ( + current_meeting + and len(meetings) < num_meetings - 1 + and current_weight + ch.weight > target_weight * 1.4 + and current_weight >= target_weight * 0.5 + ): + meetings.append(current_meeting) + current_meeting = [] + current_weight = 0.0 + + current_meeting.append(ch) + current_weight += ch.weight + + # Add the last meeting + if current_meeting: + meetings.append(current_meeting) + + # Pad or balance: if we have fewer meetings than requested, split the largest + while len(meetings) < num_meetings and len(meetings) >= 1: + # Find the largest meeting to split + largest_idx = max( + range(len(meetings)), + key=lambda i: sum(c.weight for c in meetings[i]), + ) + largest = meetings[largest_idx] + if len(largest) <= 1: + break + mid = len(largest) // 2 + meetings[largest_idx] = largest[:mid] + meetings.insert(largest_idx + 1, largest[mid:]) + + # If we have more meetings than requested (shouldn't normally happen), + # merge the smallest adjacent pair + while len(meetings) > num_meetings: + best_merge = min( + range(len(meetings) - 1), + key=lambda i: sum(c.weight for c in meetings[i]) + + sum(c.weight for c in meetings[i + 1]), + ) + merged = meetings[best_merge] + meetings[best_merge + 1] + meetings[best_merge] = merged + del meetings[best_merge + 1] + + return meetings + + +def generate_recommendations( + ebook: EBook, + user, + meeting_count: int = DEFAULT_MEETING_COUNT, +) -> ReadingSchedule: + """Generate section-based reading recommendations for an ebook. + + Creates (or replaces) a ReadingSchedule with MeetingSections. + + Args: + ebook: The EBook to generate recommendations for. + user: The requesting user. + meeting_count: Number of weekly meetings (default 4). + + Returns: + The created ReadingSchedule instance. + """ + chapters = _collect_chapters(ebook) + + # Delete any existing schedule for this ebook/user + ReadingSchedule.objects.filter(ebook=ebook, user=user).delete() + + schedule = ReadingSchedule.objects.create( + ebook=ebook, + user=user, + meeting_count=meeting_count, + ) + + if not chapters: + # Create empty meetings + for i in range(meeting_count): + MeetingSection.objects.create( + schedule=schedule, + meeting_index=i, + title=f"Week {i + 1}", + ) + return schedule + + partition = _partition_chapters(chapters, meeting_count) + + for meeting_idx, meeting_chapters in enumerate(partition): + chapter_ids = [ch.chapter_id for ch in meeting_chapters] + total_weight = sum(ch.weight for ch in meeting_chapters) + + # Build a descriptive title from first and last chapter + first = meeting_chapters[0].title if meeting_chapters else "No chapters" + last = meeting_chapters[-1].title if len(meeting_chapters) > 1 else "" + if last and last != first: + title = f"{first} → {last}" + else: + title = first + + # Truncate title if too long + if len(title) > 200: + title = title[:197] + "..." + + # Estimate time: ~5 minutes per weight point (adjustable heuristic) + estimated_time = max(5, int(total_weight * 5)) + + MeetingSection.objects.create( + schedule=schedule, + meeting_index=meeting_idx, + title=title, + estimated_time_minutes=estimated_time, + chapter_ids=chapter_ids, + ) + + return schedule + + +def regenerate_recommendations(schedule: ReadingSchedule) -> ReadingSchedule: + """Regenerate recommendations for an existing schedule.""" + return generate_recommendations( + ebook=schedule.ebook, + user=schedule.user, + meeting_count=schedule.meeting_count, + ) + + +def confirm_schedule(schedule: ReadingSchedule) -> ReadingSchedule: + """Confirm/finalize a reading schedule.""" + schedule.confirmed = True + schedule.save(update_fields=["confirmed", "updated_at"]) + return schedule diff --git a/backend/apps/books/urls.py b/backend/apps/books/urls.py index 730259c..ebff5c2 100644 --- a/backend/apps/books/urls.py +++ b/backend/apps/books/urls.py @@ -1,10 +1,11 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view +from apps.books.views import BookViewSet, EBookViewSet, ScheduleViewSet, book_reading_settings_view router = DefaultRouter() router.register(r"ebooks", EBookViewSet, basename="ebook") +router.register(r"schedules", ScheduleViewSet, basename="schedule") router.register(r"", BookViewSet, basename="book") urlpatterns = [ diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index de08a53..086f2f2 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -13,12 +13,13 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress +from apps.books.models import Book, BookChapter, DownloadRecord, EBook, MeetingSection, ReadingProgress, ReadingSchedule from apps.books.serializers import ( BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer, - BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, - EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, - ReadingProgressSerializer, StorageSummarySerializer, + BookSerializer, ConfirmScheduleSerializer, DownloadRecordSerializer, EBookContentSerializer, + EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, + GenerateRecommendationsSerializer, MeetingSectionSerializer, MeetingSectionUpdateSerializer, + ReadingProgressSerializer, ReadingScheduleSerializer, StorageSummarySerializer, ) from apps.reader.models import ReadingSettings from apps.books.services.ebook_metadata import subjects_from_ebook @@ -323,4 +324,171 @@ def book_reading_settings_view(request: Request) -> Response: serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) + + +class IsScheduleOwner(permissions.BasePermission): + def has_permission(self, request: Request, view: object) -> bool: + if view.action == "create": + ebook_id = request.data.get("ebook") or request.query_params.get("ebook_id") + if ebook_id: + return EBook.objects.filter(id=ebook_id, user=request.user).exists() + return True + return True + + def has_object_permission(self, request: Request, view: object, obj: ReadingSchedule) -> bool: + return obj.user == request.user + + +class ScheduleViewSet(viewsets.ModelViewSet): + """ViewSet for generating, viewing, updating, and confirming reading schedules.""" + + permission_classes = [IsAuthenticated, IsScheduleOwner] + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + + def get_serializer_class(self): + if self.action == "generate": + return GenerateRecommendationsSerializer + if self.action == "confirm": + return ConfirmScheduleSerializer + if self.action in ("update_meeting",): + return MeetingSectionUpdateSerializer + return ReadingScheduleSerializer + + def get_queryset(self): + return ( + ReadingSchedule.objects.filter(user=self.request.user) + .select_related("ebook") + .prefetch_related("meetings") + .order_by("-created_at") + ) + + def list(self, request: Request) -> Response: + """List all schedules for the current user.""" + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request: Request, pk: int | None = None) -> Response: + """Get a single schedule with meetings.""" + schedule = self.get_object() + serializer = self.get_serializer(schedule) + return Response(serializer.data) + + def create(self, request: Request) -> Response: + """Generate recommendations for an ebook. Expects {ebook_id, meeting_count?}.""" + serializer = GenerateRecommendationsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + meeting_count = serializer.validated_data["meeting_count"] + + ebook_id = request.data.get("ebook") or request.data.get("ebook_id") + if not ebook_id: + return Response( + {"error": "ebook (id) is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + ebook = EBook.objects.get(id=ebook_id, user=request.user) + except EBook.DoesNotExist: + return Response( + {"error": "EBook not found or not owned by you."}, + status=status.HTTP_404_NOT_FOUND, + ) + + from apps.books.services.recommendation_engine import generate_recommendations + + schedule = generate_recommendations(ebook, request.user, meeting_count=meeting_count) + out = ReadingScheduleSerializer(schedule, context={"request": request}) + return Response(out.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"]) + def generate(self, request: Request, pk: int | None = None) -> Response: + """Re-generate recommendations for an existing schedule.""" + schedule = self.get_object() + serializer = GenerateRecommendationsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + meeting_count = serializer.validated_data["meeting_count"] + + from apps.books.services.recommendation_engine import regenerate_recommendations + + schedule.meeting_count = meeting_count + schedule.save(update_fields=["meeting_count", "updated_at"]) + schedule = regenerate_recommendations(schedule) + out = ReadingScheduleSerializer(schedule, context={"request": request}) + return Response(out.data) + + @action(detail=True, methods=["post"]) + def confirm(self, request: Request, pk: int | None = None) -> Response: + """Confirm/finalize a schedule.""" + schedule = self.get_object() + serializer = ConfirmScheduleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + from apps.books.services.recommendation_engine import confirm_schedule + + schedule = confirm_schedule(schedule) + out = ReadingScheduleSerializer(schedule, context={"request": request}) + return Response(out.data) + + @action(detail=True, methods=["patch"], url_path="meetings/(?P[^/.]+)") + def update_meeting(self, request: Request, pk: int | None = None, + meeting_pk: str | None = None) -> Response: + """Update a single meeting section (drag-and-drop reassignment).""" + schedule = self.get_object() + try: + meeting = schedule.meetings.get(pk=meeting_pk) + except MeetingSection.DoesNotExist: + return Response( + {"error": "Meeting section not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = MeetingSectionUpdateSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + if "chapter_ids" in data: + meeting.chapter_ids = data["chapter_ids"] + if "title" in data: + meeting.title = data["title"] + if "meeting_index" in data: + meeting.meeting_index = data["meeting_index"] + + # Recalculate estimated time + chapter_count = len(meeting.chapter_ids) if meeting.chapter_ids else 0 + meeting.estimated_time_minutes = max(5, chapter_count * 5) + meeting.save(update_fields=["chapter_ids", "title", "meeting_index", "estimated_time_minutes"]) + + out = MeetingSectionSerializer(meeting, context={"request": request}) + return Response(out.data) + + @action(detail=True, methods=["patch"], url_path="reorder") + def reorder_meetings(self, request: Request, pk: int | None = None) -> Response: + """Reorder meetings. Expects {meetings: [{id, meeting_index}, ...]}.""" + schedule = self.get_object() + meetings_data = request.data.get("meetings", []) + if not isinstance(meetings_data, list): + return Response( + {"error": "meetings must be a list."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + meeting_map = {m.id: m for m in schedule.meetings.all()} + updated: list[MeetingSection] = [] + for entry in meetings_data: + mid = entry.get("id") + new_index = entry.get("meeting_index") + if mid is None or new_index is None: + continue + m = meeting_map.get(mid) + if m is None: + continue + m.meeting_index = new_index + updated.append(m) + + MeetingSection.objects.bulk_update(updated, ["meeting_index"]) + + schedule.refresh_from_db() + out = ReadingScheduleSerializer(schedule, context={"request": request}) + return Response(out.data) \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45ba735..188464f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { : } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts index 6db89dc..09f506a 100644 --- a/frontend/src/api/books.ts +++ b/frontend/src/api/books.ts @@ -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("/books/settings/", settingsData); return data; }, +}; + +export const schedulesApi = { + async getSchedules(): Promise { + const { data } = await api.get("/books/schedules/"); + return data; + }, + + async getSchedule(id: number): Promise { + const { data } = await api.get(`/books/schedules/${id}/`); + return data; + }, + + async generateRecommendations(request: GenerateRecommendationsRequest): Promise { + const { data } = await api.post("/books/schedules/", { + ebook_id: request.ebook_id, + meeting_count: request.meeting_count ?? 4, + }); + return data; + }, + + async regenerateRecommendations(scheduleId: number, meetingCount?: number): Promise { + const { data } = await api.post(`/books/schedules/${scheduleId}/generate/`, { + meeting_count: meetingCount ?? 4, + }); + return data; + }, + + async confirmSchedule(scheduleId: number): Promise { + const { data } = await api.post(`/books/schedules/${scheduleId}/confirm/`, { + confirm: true, + }); + return data; + }, + + async updateMeeting( + scheduleId: number, + meetingId: number, + update: { chapter_ids?: number[]; title?: string; meeting_index?: number }, + ): Promise { + const { data } = await api.patch( + `/books/schedules/${scheduleId}/meetings/${meetingId}/`, + update, + ); + return data; + }, + + async reorderMeetings( + scheduleId: number, + meetings: { id: number; meeting_index: number }[], + ): Promise { + const { data } = await api.patch( + `/books/schedules/${scheduleId}/reorder/`, + { meetings }, + ); + return data; + }, + + async deleteSchedule(id: number): Promise { + await api.delete(`/books/schedules/${id}/`); + }, }; \ No newline at end of file diff --git a/frontend/src/components/BookContextMenu.tsx b/frontend/src/components/BookContextMenu.tsx index 7c597cf..9cdb21d 100644 --- a/frontend/src/components/BookContextMenu.tsx +++ b/frontend/src/components/BookContextMenu.tsx @@ -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(null); const [position, setPosition] = useState({ left: x, top: y }); const [activeAction, setActiveAction] = useState(null); @@ -148,6 +150,18 @@ export function BookContextMenu({ > {activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")} +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ Reading Schedule +

+ {ebook && ( +

+ {ebook.title}{ebook.author ? ` — ${ebook.author}` : ""} +

+ )} +
+ {!schedule && ( + + )} + {schedule && ( +
+ + {!schedule.confirmed && ( + + )} +
+ )} +
+ + {/* Status badge */} + {schedule && ( +
+ + {schedule.confirmed ? "✓ Confirmed" : "Draft — drag chapters to adjust"} + +
+ )} + + {/* No schedule yet */} + {!schedule && ( +
+
📅
+

+ No Schedule Yet +

+

+ Generate a reading schedule to split this book into balanced weekly assignments for your group. +

+ +
+ )} + + {/* Schedule meetings */} + {schedule && ( + <> + {/* Unassigned drop zone */} +
{ 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"} +
+ + {/* Meetings grid */} +
+ {schedule.meetings.map((meeting, idx) => ( + void handleDrop(meeting.id)} + onDragEnd={handleDragEnd} + /> + ))} +
+ + )} + + {/* Drag overlay instruction */} + {draggedChapter && ( +
+ Drop the chapter into a meeting column +
+ )} +
+ ); +} + +/* ---- 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 ( +
+
+
+ Week {index + 1} +
+
+ {chapterCount} {chapterCount === 1 ? "chapter" : "chapters"} + {formatTime(meeting.estimated_time_minutes)} +
+
+
+ {meeting.chapters.length === 0 && ( +
+ {isDropTarget ? "Drop here" : "No chapters assigned"} +
+ )} + {meeting.chapters.map((chapter) => ( +
+
{chapter.title}
+ {chapter.href && ( +
{chapter.href}
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/types/book.ts b/frontend/src/types/book.ts index 06e27a3..bc9d42c 100644 --- a/frontend/src/types/book.ts +++ b/frontend/src/types/book.ts @@ -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; } \ No newline at end of file -- 2.54.0