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
+44 -1
View File
@@ -153,4 +153,47 @@ class ReadingProgress(models.Model):
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
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}"