Compare commits

..
Author SHA1 Message Date
Marko e80c2673ef feat: implement reading pace notifications (US #33)
Backend:
- New hermes app with ReadingGroup, GroupMeeting, GroupMembership models
- NotificationPreference (per-user: enable/disable, frequency daily/weekly)
- PaceNotification model for tracking sent/dismissed notifications
- Pace calculation service comparing current progress vs meeting targets
- API endpoints: pace status, notification preferences CRUD, reminders, dismiss
- Ahead/behind/on_track/completed status determination

Frontend:
- PaceNotification component for in-app alerts (behind/ahead/on_track statuses)
- ReadingPaceBanner — persistent banner in reader view
- Notification settings in Settings page (enable/disable, frequency, reminders)
- API client module (groupsApi) for all notification endpoints
2026-06-20 19:33:41 +00:00
28 changed files with 1092 additions and 2267 deletions
+23 -31
View File
@@ -1,46 +1,38 @@
from django.contrib import admin from django.contrib import admin
from apps.groups.models import ( from apps.groups.models import (
GroupBook, GroupMeeting,
MemberProgress, GroupMembership,
NotificationPreference,
PaceNotification,
ReadingGroup, ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
) )
@admin.register(ReadingGroup) @admin.register(ReadingGroup)
class ReadingGroupAdmin(admin.ModelAdmin): class ReadingGroupAdmin(admin.ModelAdmin):
list_display = ["name", "admin", "created_at"] list_display = ["name", "created_by", "created_at"]
search_fields = ["name", "admin__email"] search_fields = ["name"]
@admin.register(ReadingGroupMembership) @admin.register(GroupMembership)
class ReadingGroupMembershipAdmin(admin.ModelAdmin): class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ["user", "group", "role", "joined_at"] list_display = ["user", "group", "role", "joined_at"]
list_filter = ["role", "group"] list_filter = ["role"]
@admin.register(GroupBook) @admin.register(GroupMeeting)
class GroupBookAdmin(admin.ModelAdmin): class GroupMeetingAdmin(admin.ModelAdmin):
list_display = ["title", "group", "status", "uploaded_by", "created_at"] list_display = ["group", "title", "target_section_label", "scheduled_at"]
list_filter = ["group"]
@admin.register(NotificationPreference)
class NotificationPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "pace_notifications_enabled", "reminder_enabled", "frequency"]
@admin.register(PaceNotification)
class PaceNotificationAdmin(admin.ModelAdmin):
list_display = ["user", "meeting", "status", "created_at"]
list_filter = ["status"] list_filter = ["status"]
search_fields = ["title", "group__name"]
@admin.register(Section)
class SectionAdmin(admin.ModelAdmin):
list_display = ["title", "group_book", "order", "estimated_reading_minutes"]
ordering = ["group_book", "order"]
@admin.register(ReadingSchedule)
class ReadingScheduleAdmin(admin.ModelAdmin):
list_display = ["group_book", "meeting_number", "week_date"]
ordering = ["group_book", "meeting_number"]
@admin.register(MemberProgress)
class MemberProgressAdmin(admin.ModelAdmin):
list_display = ["user", "group_book", "current_section", "updated_at"]
+1
View File
@@ -5,3 +5,4 @@ class GroupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.groups" name = "apps.groups"
verbose_name = "Reading Groups" verbose_name = "Reading Groups"
label = "groups"
+58 -103
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.1.7 on 2026-06-20 19:21 # Generated by Django 5.1.7 on 2026-06-20 19:22
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -11,10 +11,27 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('books', '0003_readingprogress_epub_location'), ('books', '0003_readingprogress_epub_location'),
('users', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='NotificationPreference',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='notification_preference', serialize=False, to=settings.AUTH_USER_MODEL)),
('pace_notifications_enabled', models.BooleanField(default=True)),
('reminder_enabled', models.BooleanField(default=True)),
('frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly')], default='daily', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Notification Preference',
'verbose_name_plural': 'Notification Preferences',
'db_table': 'groups_notification_preference',
},
),
migrations.CreateModel( migrations.CreateModel(
name='ReadingGroup', name='ReadingGroup',
fields=[ fields=[
@@ -22,8 +39,9 @@ class Migration(migrations.Migration):
('name', models.CharField(db_index=True, max_length=256)), ('name', models.CharField(db_index=True, max_length=256)),
('description', models.TextField(blank=True, default='')), ('description', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.book')),
('admin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='administered_groups', to=settings.AUTH_USER_MODEL)), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups', to=settings.AUTH_USER_MODEL)),
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.ebook')),
], ],
options={ options={
'verbose_name': 'Reading Group', 'verbose_name': 'Reading Group',
@@ -33,122 +51,59 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='GroupBook', name='GroupMeeting',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(db_index=True, max_length=512)), ('title', models.CharField(blank=True, default='', max_length=512)),
('status', models.CharField(choices=[('active', 'Active'), ('replaced', 'Replaced')], db_index=True, default='active', max_length=16)), ('scheduled_at', models.DateTimeField(db_index=True)),
('target_section', models.PositiveIntegerField(default=0, help_text='Target chapter/section index to reach by this meeting')),
('target_section_label', models.CharField(blank=True, default='', help_text="Human-readable label for the target section (e.g. 'Section 3')", max_length=512)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='groups.readinggroup')),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='books.ebook')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_group_books', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='groups.readinggroup')),
], ],
options={ options={
'verbose_name': 'Group Book', 'verbose_name': 'Group Meeting',
'verbose_name_plural': 'Group Books', 'verbose_name_plural': 'Group Meetings',
'db_table': 'groups_group_book', 'db_table': 'groups_meeting',
'ordering': ['-created_at'], 'ordering': ['scheduled_at'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ReadingGroupMembership', name='PaceNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('dismissed', 'Dismissed')], default='pending', max_length=16)),
('current_section', models.PositiveIntegerField(default=0)),
('target_section', models.PositiveIntegerField(default=0)),
('ahead_behind_delta', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='books.ebook')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='groups.groupmeeting')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Pace Notification',
'verbose_name_plural': 'Pace Notifications',
'db_table': 'groups_pace_notification',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'meeting', 'ebook'], name='groups_pace_user_id_18e929_idx')],
},
),
migrations.CreateModel(
name='GroupMembership',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)), ('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
('joined_at', models.DateTimeField(auto_now_add=True)), ('joined_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name': 'Reading Group Membership', 'verbose_name': 'Group Membership',
'verbose_name_plural': 'Reading Group Memberships', 'verbose_name_plural': 'Group Memberships',
'db_table': 'groups_membership', 'db_table': 'groups_membership',
'indexes': [models.Index(fields=['user', 'group'], name='groups_memb_user_id_9cda7e_idx')],
'unique_together': {('user', 'group')},
}, },
), ),
migrations.AddField(
model_name='readinggroup',
name='members',
field=models.ManyToManyField(related_name='reading_groups', through='groups.ReadingGroupMembership', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='ReadingSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('meeting_number', models.PositiveIntegerField()),
('week_date', models.DateField()),
('section_ids', models.JSONField(default=list, help_text='Ordered list of Section IDs for this meeting')),
('created_at', models.DateTimeField(auto_now_add=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='groups.groupbook')),
],
options={
'verbose_name': 'Reading Schedule',
'verbose_name_plural': 'Reading Schedules',
'db_table': 'groups_reading_schedule',
'ordering': ['meeting_number'],
},
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('order', models.PositiveIntegerField(db_index=True)),
('start_chapter_index', models.PositiveIntegerField(default=0)),
('end_chapter_index', models.PositiveIntegerField(default=0)),
('estimated_reading_minutes', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='groups.groupbook')),
],
options={
'verbose_name': 'Section',
'verbose_name_plural': 'Sections',
'db_table': 'groups_section',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='MemberProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('completed_sections', models.JSONField(default=list, help_text='Ordered list of completed Section IDs')),
('updated_at', models.DateTimeField(auto_now=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.groupbook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_progress', to=settings.AUTH_USER_MODEL)),
('current_section', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_readers', to='groups.section')),
],
options={
'verbose_name': 'Member Progress',
'verbose_name_plural': 'Member Progress',
'db_table': 'groups_member_progress',
},
),
migrations.AddIndex(
model_name='groupbook',
index=models.Index(fields=['group', 'status'], name='groups_grou_group_i_afdbf7_idx'),
),
migrations.AlterUniqueTogether(
name='readinggroupmembership',
unique_together={('user', 'group')},
),
migrations.AddIndex(
model_name='readingschedule',
index=models.Index(fields=['group_book', 'meeting_number'], name='groups_read_group_b_2fc416_idx'),
),
migrations.AlterUniqueTogether(
name='readingschedule',
unique_together={('group_book', 'meeting_number')},
),
migrations.AddIndex(
model_name='section',
index=models.Index(fields=['group_book', 'order'], name='groups_sect_group_b_e2810a_idx'),
),
migrations.AlterUniqueTogether(
name='section',
unique_together={('group_book', 'order')},
),
migrations.AlterUniqueTogether(
name='memberprogress',
unique_together={('user', 'group_book')},
),
] ]
+133 -104
View File
@@ -1,26 +1,50 @@
from __future__ import annotations
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from apps.books.models import EBook
class FrequencyChoice(models.TextChoices):
DAILY = "daily", "Daily"
WEEKLY = "weekly", "Weekly"
class MembershipRole(models.TextChoices):
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
DISMISSED = "dismissed", "Dismissed"
class ReadingGroup(models.Model): class ReadingGroup(models.Model):
"""A reading group with an admin and members.""" """A group of users reading a book together with scheduled meetings."""
name = models.CharField(max_length=256, db_index=True) name = models.CharField(max_length=256, db_index=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
admin = models.ForeignKey( book = models.ForeignKey(
settings.AUTH_USER_MODEL, "books.Book",
on_delete=models.CASCADE, on_delete=models.SET_NULL,
related_name="administered_groups", null=True,
) blank=True,
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through="ReadingGroupMembership",
related_name="reading_groups", related_name="reading_groups",
) )
ebook = models.ForeignKey(
"books.EBook",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reading_groups",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="created_groups",
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "groups_reading_group" db_table = "groups_reading_group"
@@ -32,130 +56,135 @@ class ReadingGroup(models.Model):
return self.name return self.name
class ReadingGroupMembership(models.Model): class GroupMembership(models.Model):
class Role(models.TextChoices): """User membership in a reading group."""
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(
group = models.ForeignKey(ReadingGroup, on_delete=models.CASCADE, related_name="memberships") settings.AUTH_USER_MODEL,
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER) on_delete=models.CASCADE,
related_name="group_memberships",
)
group = models.ForeignKey(
ReadingGroup,
on_delete=models.CASCADE,
related_name="memberships",
)
role = models.CharField(
max_length=16,
choices=MembershipRole.choices,
default=MembershipRole.MEMBER,
)
joined_at = models.DateTimeField(auto_now_add=True) joined_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = "groups_membership" db_table = "groups_membership"
verbose_name = "Group Membership"
verbose_name_plural = "Group Memberships"
unique_together = [("user", "group")] unique_together = [("user", "group")]
verbose_name = "Reading Group Membership" indexes = [models.Index(fields=["user", "group"])]
verbose_name_plural = "Reading Group Memberships"
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} in {self.group.name} ({self.role})" return f"{self.user} in {self.group}"
class GroupBook(models.Model): class GroupMeeting(models.Model):
"""Links an uploaded EBook to a reading group.""" """A scheduled meeting for a reading group with a target section."""
class Status(models.TextChoices):
ACTIVE = "active", "Active"
REPLACED = "replaced", "Replaced"
group = models.ForeignKey( group = models.ForeignKey(
ReadingGroup, on_delete=models.CASCADE, related_name="group_books" ReadingGroup,
on_delete=models.CASCADE,
related_name="meetings",
) )
ebook = models.ForeignKey( title = models.CharField(max_length=512, blank=True, default="")
EBook, on_delete=models.CASCADE, related_name="group_books" scheduled_at = models.DateTimeField(db_index=True)
target_section = models.PositiveIntegerField(
default=0,
help_text="Target chapter/section index to reach by this meeting",
) )
uploaded_by = models.ForeignKey( target_section_label = models.CharField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_group_books" max_length=512,
blank=True,
default="",
help_text="Human-readable label for the target section (e.g. 'Section 3')",
) )
title = models.CharField(max_length=512, db_index=True) created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.ACTIVE, db_index=True class Meta:
db_table = "groups_meeting"
verbose_name = "Group Meeting"
verbose_name_plural = "Group Meetings"
ordering = ["scheduled_at"]
def __str__(self) -> str:
return f"{self.group.name}{self.title or self.target_section_label} ({self.scheduled_at.date()})"
class NotificationPreference(models.Model):
"""Per-user notification settings for reading pace alerts."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preference",
primary_key=True,
)
pace_notifications_enabled = models.BooleanField(default=True)
reminder_enabled = models.BooleanField(default=True)
frequency = models.CharField(
max_length=16,
choices=FrequencyChoice.choices,
default=FrequencyChoice.DAILY,
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "groups_group_book" db_table = "groups_notification_preference"
verbose_name = "Group Book" verbose_name = "Notification Preference"
verbose_name_plural = "Group Books" verbose_name_plural = "Notification Preferences"
ordering = ["-created_at"]
indexes = [models.Index(fields=["group", "status"])]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.group.name}{self.title}" return f"{self.user}pace={'on' if self.pace_notifications_enabled else 'off'}, freq={self.frequency}"
class Section(models.Model): class PaceNotification(models.Model):
"""A section/chunk of a GroupBook for group reading.""" """Tracks reading pace notifications sent to users to avoid duplicates."""
group_book = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="sections"
)
title = models.CharField(max_length=512)
order = models.PositiveIntegerField(db_index=True)
start_chapter_index = models.PositiveIntegerField(default=0)
end_chapter_index = models.PositiveIntegerField(default=0)
estimated_reading_minutes = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_section"
verbose_name = "Section"
verbose_name_plural = "Sections"
ordering = ["order"]
unique_together = [("group_book", "order")]
indexes = [models.Index(fields=["group_book", "order"])]
def __str__(self) -> str:
return f"{self.group_book.title} — Section {self.order}: {self.title}"
class ReadingSchedule(models.Model):
"""Reading schedule mapping sections to weekly meetings."""
group_book = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="schedules"
)
meeting_number = models.PositiveIntegerField()
week_date = models.DateField()
section_ids = models.JSONField(default=list, help_text="Ordered list of Section IDs for this meeting")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_reading_schedule"
verbose_name = "Reading Schedule"
verbose_name_plural = "Reading Schedules"
ordering = ["meeting_number"]
unique_together = [("group_book", "meeting_number")]
indexes = [models.Index(fields=["group_book", "meeting_number"])]
def __str__(self) -> str:
return f"{self.group_book.title} — Meeting {self.meeting_number}"
class MemberProgress(models.Model):
"""Tracks a member's progress through sections of a group book."""
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="group_progress" settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="pace_notifications",
) )
group_book = models.ForeignKey( meeting = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="member_progress" GroupMeeting,
on_delete=models.CASCADE,
related_name="pace_notifications",
) )
current_section = models.ForeignKey( ebook = models.ForeignKey(
Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="current_readers" "books.EBook",
on_delete=models.CASCADE,
related_name="pace_notifications",
null=True,
blank=True,
) )
completed_sections = models.JSONField( status = models.CharField(
default=list, help_text="Ordered list of completed Section IDs" max_length=16,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
) )
updated_at = models.DateTimeField(auto_now=True) current_section = models.PositiveIntegerField(default=0)
target_section = models.PositiveIntegerField(default=0)
ahead_behind_delta = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = "groups_member_progress" db_table = "groups_pace_notification"
verbose_name = "Member Progress" verbose_name = "Pace Notification"
verbose_name_plural = "Member Progress" verbose_name_plural = "Pace Notifications"
unique_together = [("user", "group_book")] ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "meeting", "ebook"]),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user}{self.group_book.title}" return f"{self.user}meeting {self.meeting_id}: {self.get_status_display()}"
+96 -144
View File
@@ -3,183 +3,135 @@ from __future__ import annotations
from rest_framework import serializers from rest_framework import serializers
from apps.groups.models import ( from apps.groups.models import (
GroupBook, FrequencyChoice,
MemberProgress, GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
ReadingGroup, ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
) )
from apps.books.serializers import EBookListSerializer from apps.groups.services.pace import PaceStatus
class ReadingGroupMembershipSerializer(serializers.ModelSerializer): class NotificationPreferenceSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True) class Meta:
user_username = serializers.CharField(source="user.username", read_only=True) model = NotificationPreference
fields = [
"pace_notifications_enabled",
"reminder_enabled",
"frequency",
]
def validate_frequency(self, value: str) -> str:
valid = {choice[0] for choice in FrequencyChoice.choices}
if value not in valid:
raise serializers.ValidationError(
f"Frequency must be one of: {', '.join(valid)}"
)
return value
class GroupMeetingSerializer(serializers.ModelSerializer):
group_name = serializers.CharField(source="group.name", read_only=True)
class Meta: class Meta:
model = ReadingGroupMembership model = GroupMeeting
fields = [ fields = [
"id", "user", "user_email", "user_username", "id",
"role", "joined_at", "group",
"group_name",
"title",
"scheduled_at",
"target_section",
"target_section_label",
"created_at",
] ]
read_only_fields = ["id", "joined_at"] read_only_fields = ["id", "created_at"]
def validate_target_section(self, value: int) -> int:
if value < 0:
raise serializers.ValidationError("Target section must be non-negative.")
return value
class ReadingGroupListSerializer(serializers.ModelSerializer): class ReadingGroupSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField() member_count = serializers.SerializerMethodField()
admin_email = serializers.CharField(source="admin.email", read_only=True) meeting_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = ReadingGroup model = ReadingGroup
fields = [ fields = [
"id", "name", "description", "admin", "admin_email", "id",
"member_count", "created_at", "updated_at", "name",
"description",
"book",
"ebook",
"created_by",
"created_at",
"member_count",
"meeting_count",
] ]
read_only_fields = ["id", "created_by", "created_at"]
def get_member_count(self, obj: ReadingGroup) -> int: def get_member_count(self, obj: ReadingGroup) -> int:
return obj.memberships.count() return obj.memberships.count()
def get_meeting_count(self, obj: ReadingGroup) -> int:
class ReadingGroupDetailSerializer(serializers.ModelSerializer): return obj.meetings.count()
admin_email = serializers.CharField(source="admin.email", read_only=True)
members = ReadingGroupMembershipSerializer(source="memberships", many=True, read_only=True)
class Meta:
model = ReadingGroup
fields = [
"id", "name", "description", "admin", "admin_email",
"members", "created_at", "updated_at",
]
class ReadingGroupCreateSerializer(serializers.ModelSerializer): class GroupMembershipSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingGroup
fields = ["name", "description"]
class AddMemberSerializer(serializers.Serializer):
user_id = serializers.IntegerField()
def validate_user_id(self, value: int) -> int:
from django.conf import settings
User = settings.AUTH_USER_MODEL
if not User.objects.filter(id=value).exists():
raise serializers.ValidationError("User not found.")
return value
class SectionSerializer(serializers.ModelSerializer):
class Meta:
model = Section
fields = [
"id", "title", "order", "start_chapter_index",
"end_chapter_index", "estimated_reading_minutes", "created_at",
]
read_only_fields = ["id", "created_at"]
class SectionAdjustSerializer(serializers.Serializer):
"""Payload for manual section adjustments (merge/split)."""
operation = serializers.ChoiceField(choices=["merge", "split"])
section_ids = serializers.ListField(
child=serializers.IntegerField(), min_length=1,
help_text="For merge: list of section IDs to merge. For split: [section_id] to split."
)
split_at = serializers.IntegerField(
required=False, default=2, min_value=2,
help_text="Number of new sections when splitting"
)
class ReadingScheduleSerializer(serializers.ModelSerializer):
section_details = serializers.SerializerMethodField()
class Meta:
model = ReadingSchedule
fields = [
"id", "meeting_number", "week_date", "section_ids",
"section_details", "created_at",
]
read_only_fields = ["id", "created_at"]
def get_section_details(self, obj: ReadingSchedule) -> list[dict]:
sections = Section.objects.filter(
id__in=obj.section_ids, group_book=obj.group_book
).order_by("order")
return SectionSerializer(sections, many=True).data
class MemberProgressSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True) user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True) group_name = serializers.CharField(source="group.name", read_only=True)
current_section_title = serializers.SerializerMethodField()
class Meta: class Meta:
model = MemberProgress model = GroupMembership
fields = [ fields = ["id", "user", "user_email", "group", "group_name", "role", "joined_at"]
"id", "user", "user_email", "user_username", read_only_fields = ["id", "joined_at"]
"current_section", "current_section_title",
"completed_sections", "updated_at",
]
read_only_fields = ["id", "updated_at"]
def get_current_section_title(self, obj: MemberProgress) -> str | None:
if obj.current_section:
return obj.current_section.title
return None
class GroupBookListSerializer(serializers.ModelSerializer): class PaceNotificationSerializer(serializers.ModelSerializer):
ebook = EBookListSerializer(read_only=True) status_display = serializers.CharField(source="get_status_display", read_only=True)
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
section_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = GroupBook model = PaceNotification
fields = [ fields = [
"id", "title", "status", "ebook", "uploaded_by", "id",
"uploaded_by_email", "section_count", "created_at", "updated_at", "user",
"meeting",
"ebook",
"status",
"status_display",
"current_section",
"target_section",
"ahead_behind_delta",
"created_at",
] ]
read_only_fields = ["id", "created_at"]
def get_section_count(self, obj: GroupBook) -> int:
return obj.sections.count()
class GroupBookDetailSerializer(serializers.ModelSerializer): class PaceStatusSerializer(serializers.Serializer):
ebook = EBookListSerializer(read_only=True) meeting_id = serializers.IntegerField()
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True) meeting_title = serializers.CharField()
sections = SectionSerializer(many=True, read_only=True) scheduled_at = serializers.CharField()
schedules = ReadingScheduleSerializer(source="schedules", many=True, read_only=True) target_section = serializers.IntegerField()
target_section_label = serializers.CharField()
class Meta: current_section = serializers.IntegerField()
model = GroupBook ahead_behind_delta = serializers.IntegerField()
fields = [ status = serializers.CharField()
"id", "group", "title", "status", "ebook", days_until_meeting = serializers.IntegerField()
"uploaded_by", "uploaded_by_email", sections_to_catch_up = serializers.IntegerField()
"sections", "schedules", "created_at", "updated_at",
]
class GroupBookCreateSerializer(serializers.Serializer):
ebook_id = serializers.IntegerField() ebook_id = serializers.IntegerField()
title = serializers.CharField(max_length=512, required=False) ebook_title = serializers.CharField()
def validate_ebook_id(self, value: int) -> int:
from apps.books.models import EBook
if not EBook.objects.filter(id=value).exists():
raise serializers.ValidationError("EBook not found.")
# Check format is EPUB
ebook = EBook.objects.get(id=value)
if ebook.format != "epub":
raise serializers.ValidationError("Only EPUB format is supported for group books.")
return value
def validate(self, data: dict) -> dict: class ReminderSerializer(serializers.Serializer):
request = self.context.get("request") meeting_id = serializers.IntegerField()
if request and not data.get("title"): meeting_title = serializers.CharField()
from apps.books.models import EBook scheduled_at = serializers.CharField()
ebook = EBook.objects.get(id=data["ebook_id"]) target_section = serializers.IntegerField()
data["title"] = ebook.title target_section_label = serializers.CharField()
return data group_name = serializers.CharField()
hours_until = serializers.IntegerField()
ebook_id = serializers.IntegerField()
ebook_title = serializers.CharField()
+219
View File
@@ -0,0 +1,219 @@
"""Pace calculation service for reading progress vs meeting targets."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TypedDict
from django.db.models import QuerySet
from django.utils import timezone
from apps.books.models import EBook, ReadingProgress
from apps.groups.models import (
GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
NotificationStatus,
)
logger = logging.getLogger(__name__)
class PaceStatus(TypedDict):
meeting_id: int
meeting_title: str
scheduled_at: str
target_section: int
target_section_label: str
current_section: int
ahead_behind_delta: int
status: str # "ahead", "behind", "on_track", "completed"
days_until_meeting: int
sections_to_catch_up: int
ebook_id: int
ebook_title: str
def get_relevant_ebooks_for_user(user) -> QuerySet[EBook]:
"""Return ebooks that belong to groups the user is a member of."""
group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
return (
EBook.objects.filter(reading_groups__in=group_ids)
.select_related("reading_progress")
.prefetch_related("chapters")
.distinct()
)
def calculate_pace(
user,
ebook: EBook | None = None,
meeting: GroupMeeting | None = None,
) -> list[PaceStatus]:
"""Calculate reading pace for a user's upcoming meetings.
Compares the user's current reading progress (chapter index) against
the target section for each upcoming meeting within 3 days.
"""
now = timezone.now()
three_days_from_now = now + timedelta(days=3)
# Find upcoming meetings for groups the user belongs to
user_group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
meetings_qs = GroupMeeting.objects.filter(
group_id__in=user_group_ids,
scheduled_at__gte=now,
scheduled_at__lte=three_days_from_now,
).select_related("group", "group__ebook")
if meeting:
meetings_qs = meetings_qs.filter(pk=meeting.pk)
meetings = list(meetings_qs)
results: list[PaceStatus] = []
for m in meetings:
group_ebook = m.group.ebook
ebooks_to_check: list[EBook] = []
if ebook:
ebooks_to_check = [ebook]
elif group_ebook:
ebooks_to_check = [group_ebook]
else:
# Check all ebooks in the group
ebooks_to_check = list(
EBook.objects.filter(reading_groups=m.group)
.select_related("reading_progress")
.prefetch_related("chapters")
)
for eb in ebooks_to_check:
pace = _compute_single_pace(user, m, eb, now)
if pace:
results.append(pace)
return results
def _compute_single_pace(
user,
meeting: GroupMeeting,
ebook: EBook,
now: datetime,
) -> PaceStatus | None:
"""Compute pace for one user + meeting + ebook combination."""
try:
progress = ebook.reading_progress
except ReadingProgress.DoesNotExist:
# No progress yet — user hasn't started reading
return None
# Current section: based on last_page (chapter index) or computed from progress %
if progress.last_page > 0:
current_section = progress.last_page
elif progress.current_position > 0:
chapter_count = ebook.chapters.count() or ebook.page_count or 1
current_section = max(1, int((progress.current_position / 100) * chapter_count))
else:
current_section = 0
target_section = meeting.target_section or 0
if target_section == 0:
return None
delta = current_section - target_section
days_until = max(0, (meeting.scheduled_at - now).days)
if current_section >= target_section:
if current_section >= target_section and progress.current_position > 0:
status = "completed"
else:
status = "ahead"
elif delta >= -1:
status = "on_track"
else:
status = "behind"
return PaceStatus(
meeting_id=meeting.pk,
meeting_title=meeting.title or meeting.target_section_label,
scheduled_at=meeting.scheduled_at.isoformat(),
target_section=target_section,
target_section_label=meeting.target_section_label,
current_section=current_section,
ahead_behind_delta=delta,
status=status,
days_until_meeting=days_until,
sections_to_catch_up=max(0, target_section - current_section),
ebook_id=ebook.pk,
ebook_title=ebook.title,
)
def should_send_notification(
user,
meeting: GroupMeeting,
ebook: EBook,
) -> bool:
"""Check if a pace notification should be sent based on user preferences."""
pref, _ = NotificationPreference.objects.get_or_create(user=user)
if not pref.pace_notifications_enabled:
return False
# Check if notification was already sent today
today = timezone.now().date()
existing = PaceNotification.objects.filter(
user=user,
meeting=meeting,
ebook=ebook,
created_at__date=today,
).exclude(status=NotificationStatus.DISMISSED)
if existing.exists():
return False
# Frequency check
if pref.frequency == "weekly":
week_ago = today - timedelta(days=7)
recent = PaceNotification.objects.filter(
user=user,
meeting=meeting,
ebook=ebook,
created_at__date__gte=week_ago,
).exclude(status=NotificationStatus.DISMISSED)
if recent.exists():
return False
return True
def get_reminder_meetings(user) -> list[GroupMeeting]:
"""Get meetings that are within 24 hours for reminder notifications."""
now = timezone.now()
tomorrow = now + timedelta(days=1)
user_group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
return list(
GroupMeeting.objects.filter(
group_id__in=user_group_ids,
scheduled_at__gte=now,
scheduled_at__lte=tomorrow,
)
.select_related("group", "group__ebook")
.order_by("scheduled_at")
)
@@ -1,241 +0,0 @@
"""Automatic section detection and meeting recommendation for group books."""
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import Any
from django.db.models import QuerySet
from apps.books.models import BookChapter, EBook
from apps.groups.models import GroupBook, ReadingSchedule, Section
logger = logging.getLogger(__name__)
WORDS_PER_MINUTE = 250 # Average reading speed
def _fetch_chapter_text(ebook: EBook, chapter: BookChapter) -> str:
"""Extract plain text from a chapter for word counting."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(ebook.file.path)
href = chapter.href or ""
for item in book.get_items():
item_name = item.get_name() or ""
if href and (item_name.endswith(href) or href.endswith(item_name)):
content = item.get_content()
soup = BeautifulSoup(content, "html.parser")
body = soup.find("body")
if body:
return body.get_text(separator=" ", strip=True)
return soup.get_text(separator=" ", strip=True)
return ""
except Exception:
logger.exception("Failed to extract text for chapter %s", chapter.id)
return ""
def _estimate_reading_minutes(text: str) -> int:
"""Estimate reading time based on word count at WORDS_PER_MINUTE."""
word_count = len(text.split())
if word_count == 0:
return 1
return max(1, round(word_count / WORDS_PER_MINUTE))
def detect_sections(group_book: GroupBook) -> list[dict[str, Any]]:
"""Auto-detect sections from a GroupBook's chapters.
Groups consecutive chapters into logical sections based on TOC structure.
Top-level TOC entries become sections; if there are very few (< 3),
groups of ~5 chapters become sections instead.
"""
ebook = group_book.ebook
chapters = list(
BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
)
if not chapters:
return []
top_level = [ch for ch in chapters if not ch.children or len(ch.children) == 0]
has_children = [ch for ch in chapters if ch.children and len(ch.children) > 0]
sections: list[dict[str, Any]] = []
if len(has_children) >= 3:
# Use TOC structure: each top-level chapter (with children) is a section
for idx, ch in enumerate(has_children):
# Find all child chapters belonging to this parent
child_indices = _collect_child_indices(chapters, ch, idx)
start_idx = ch.index
end_idx = child_indices[-1] if child_indices else start_idx
section_chapters = [c for c in chapters if start_idx <= c.index <= end_idx]
total_text = ""
for sc in section_chapters:
total_text += " " + _fetch_chapter_text(ebook, sc)
sections.append({
"title": ch.title,
"order": idx + 1,
"start_chapter_index": start_idx,
"end_chapter_index": end_idx + 1,
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
})
else:
# Group chapters into chunks of ~5
chunk_size = max(1, len(top_level) // 6 if len(top_level) > 6 else 5)
chunk_size = max(3, min(chunk_size, 10))
group_start = 0
section_order = 1
total = len(top_level) or len(chapters)
source = top_level or chapters
while group_start < total:
group_end = min(group_start + chunk_size, total)
chunk = source[group_start:group_end]
total_text = ""
for ch in chunk:
total_text += " " + _fetch_chapter_text(ebook, ch)
first_title = chunk[0].title if chunk else "Section"
last_title = chunk[-1].title if len(chunk) > 1 else ""
title = f"{first_title}" if not last_title or first_title == last_title else f"{first_title}{last_title}"
sections.append({
"title": title,
"order": section_order,
"start_chapter_index": chunk[0].index,
"end_chapter_index": chunk[-1].index + 1,
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
})
group_start = group_end
section_order += 1
return sections
def _collect_child_indices(chapters: list[BookChapter], parent: BookChapter, parent_idx: int) -> list[int]:
"""Collect indices of all chapters that are children of the given parent TOC entry."""
indices: list[int] = [parent.index]
child_hrefs: set[str] = set()
for child in parent.children:
if isinstance(child, dict):
child_hrefs.add(child.get("href", ""))
elif hasattr(child, "href"):
child_hrefs.add(getattr(child, "href", ""))
for ch in chapters:
if ch.index == parent.index:
continue
if ch.href in child_hrefs or any(
ch.href.endswith(h) or h.endswith(ch.href) for h in child_hrefs
):
indices.append(ch.index)
# Also include chapters between this parent and the next parent
if parent_idx + 1 < len(chapters):
next_parent = chapters[parent_idx + 1]
for ch in chapters:
if parent.index < ch.index < next_parent.index:
indices.append(ch.index)
return sorted(set(indices))
def apply_sections(group_book: GroupBook) -> list[Section]:
"""Detect sections and persist them to the database, replacing existing ones."""
Section.objects.filter(group_book=group_book).delete()
sections_data = detect_sections(group_book)
created: list[Section] = []
for data in sections_data:
section = Section.objects.create(
group_book=group_book,
title=data["title"],
order=data["order"],
start_chapter_index=data["start_chapter_index"],
end_chapter_index=data["end_chapter_index"],
estimated_reading_minutes=data["estimated_reading_minutes"],
)
created.append(section)
return created
def recommend_meetings(group_book: GroupBook, num_meetings: int = 4) -> list[dict[str, Any]]:
"""Recommend which sections to assign to each weekly meeting.
Distributes sections across meetings, trying to balance total reading time.
Returns a list of meeting assignments ready for schedule creation.
"""
sections = list(
Section.objects.filter(group_book=group_book).order_by("order")
)
if not sections:
return []
# Calculate total minutes to distribute
total_minutes = sum(s.estimated_reading_minutes for s in sections)
target_per_meeting = total_minutes / num_meetings
meetings: list[dict[str, Any]] = []
current_meeting: list[int] = []
current_minutes = 0
for section in sections:
if current_meeting and current_minutes + section.estimated_reading_minutes > target_per_meeting * 1.4:
# Start new meeting if adding this section would overshoot too much
if len(meetings) < num_meetings - 1:
meetings.append({
"meeting_number": len(meetings) + 1,
"section_ids": current_meeting,
"total_minutes": current_minutes,
})
current_meeting = []
current_minutes = 0
current_meeting.append(section.id)
current_minutes += section.estimated_reading_minutes
# Add the last meeting
if current_meeting:
meetings.append({
"meeting_number": len(meetings) + 1,
"section_ids": current_meeting,
"total_minutes": current_minutes,
})
# If we have fewer than num_meetings, we could split the largest one
# For now, just return what we have
return meetings
def apply_schedule(group_book: GroupBook, num_meetings: int = 4) -> list[ReadingSchedule]:
"""Generate and persist a reading schedule."""
ReadingSchedule.objects.filter(group_book=group_book).delete()
recommendations = recommend_meetings(group_book, num_meetings)
today = date.today()
created: list[ReadingSchedule] = []
for rec in recommendations:
week_date = today + timedelta(weeks=rec["meeting_number"] - 1)
schedule = ReadingSchedule.objects.create(
group_book=group_book,
meeting_number=rec["meeting_number"],
week_date=week_date,
section_ids=rec["section_ids"],
)
created.append(schedule)
return created
+14 -34
View File
@@ -1,43 +1,23 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.groups.views import GroupBookViewSet, ReadingGroupViewSet from apps.groups.views import (
GroupMeetingViewSet,
ReadingGroupViewSet,
dismiss_pace_notification_view,
notification_preferences_view,
pace_status_view,
reminder_view,
)
# ReadingGroup routes (standard ViewSet)
router = DefaultRouter() router = DefaultRouter()
router.register(r"", ReadingGroupViewSet, basename="group") router.register(r"groups", ReadingGroupViewSet, basename="reading-group")
router.register(r"meetings", GroupMeetingViewSet, basename="group-meeting")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
# Nested GroupBook routes under a group path("notifications/preferences/", notification_preferences_view, name="notification-preferences"),
path( path("notifications/pace/", pace_status_view, name="pace-status"),
"<int:group_pk>/books/", path("notifications/dismiss/", dismiss_pace_notification_view, name="dismiss-pace"),
GroupBookViewSet.as_view({"get": "list", "post": "create"}), path("notifications/reminders/", reminder_view, name="meeting-reminders"),
name="group-book-list",
),
path(
"<int:group_pk>/books/<int:pk>/",
GroupBookViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
name="group-book-detail",
),
path(
"<int:group_pk>/books/<int:pk>/detect-sections/",
GroupBookViewSet.as_view({"post": "detect_sections_action"}),
name="group-book-detect-sections",
),
path(
"<int:group_pk>/books/<int:pk>/adjust-sections/",
GroupBookViewSet.as_view({"post": "adjust_sections"}),
name="group-book-adjust-sections",
),
path(
"<int:group_pk>/books/<int:pk>/schedule/",
GroupBookViewSet.as_view({"get": "schedule", "post": "schedule", "delete": "schedule"}),
name="group-book-schedule",
),
path(
"<int:group_pk>/books/<int:pk>/progress/",
GroupBookViewSet.as_view({"get": "progress", "patch": "progress"}),
name="group-book-progress",
),
] ]
+176 -318
View File
@@ -1,349 +1,207 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any
from django.db.models import QuerySet from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from rest_framework import permissions, status, viewsets from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.books.models import EBook from apps.books.models import EBook
from apps.groups.models import ( from apps.groups.models import (
GroupBook, GroupMeeting,
MemberProgress, GroupMembership,
NotificationPreference,
PaceNotification,
NotificationStatus,
ReadingGroup, ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
) )
from apps.groups.serializers import ( from apps.groups.serializers import (
AddMemberSerializer, GroupMeetingSerializer,
GroupBookCreateSerializer, GroupMembershipSerializer,
GroupBookDetailSerializer, NotificationPreferenceSerializer,
GroupBookListSerializer, PaceNotificationSerializer,
MemberProgressSerializer, PaceStatusSerializer,
ReadingGroupCreateSerializer, ReadingGroupSerializer,
ReadingGroupDetailSerializer, ReminderSerializer,
ReadingGroupListSerializer,
ReadingScheduleSerializer,
SectionAdjustSerializer,
SectionSerializer,
) )
from apps.groups.services.section_splitting import ( from apps.groups.services.pace import (
apply_schedule, calculate_pace,
apply_sections, get_reminder_meetings,
detect_sections, should_send_notification,
) )
logger = logging.getLogger(__name__)
class IsGroupAdmin(permissions.BasePermission):
"""Only the group admin can modify group resources."""
def has_permission(self, request: Request, view: object) -> bool:
if not request.user.is_authenticated:
return False
if view.action in ("list", "retrieve"):
return True
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
if group_id:
return ReadingGroup.objects.filter(id=group_id, admin=request.user).exists()
return True
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
if request.method in permissions.SAFE_METHODS:
return True
return obj.admin == request.user
class IsGroupMember(permissions.BasePermission): class IsGroupMember(permissions.BasePermission):
"""Only group members (including admin) can view group content.""" """Only allow group members to access group resources."""
def has_permission(self, request: Request, view: object) -> bool: def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
if not request.user.is_authenticated: return obj.memberships.filter(user=request.user).exists()
return False
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
if group_id:
return ReadingGroupMembership.objects.filter(
group_id=group_id, user=request.user
).exists()
return True
class ReadingGroupViewSet(viewsets.ModelViewSet): class ReadingGroupViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin] permission_classes = [IsAuthenticated, IsGroupMember]
queryset = ReadingGroup.objects.prefetch_related("memberships__user") serializer_class = ReadingGroupSerializer
def get_serializer_class(self):
if self.action == "create":
return ReadingGroupCreateSerializer
if self.action == "retrieve":
return ReadingGroupDetailSerializer
return ReadingGroupListSerializer
def get_queryset(self) -> QuerySet[ReadingGroup]: def get_queryset(self) -> QuerySet[ReadingGroup]:
user = self.request.user return (
return ReadingGroup.objects.filter( ReadingGroup.objects.filter(memberships__user=self.request.user)
memberships__user=user .prefetch_related("memberships", "meetings")
).prefetch_related("memberships__user").distinct() .distinct()
def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup:
group = serializer.save(admin=self.request.user)
ReadingGroupMembership.objects.create(
user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN
)
return group
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
def add_member(self, request: Request, pk: int | None = None) -> Response:
group = self.get_object()
member_serializer = AddMemberSerializer(data=request.data)
member_serializer.is_valid(raise_exception=True)
user_id = member_serializer.validated_data["user_id"]
from django.conf import settings
User = settings.AUTH_USER_MODEL
new_member = get_object_or_404(User, id=user_id)
if ReadingGroupMembership.objects.filter(group=group, user=new_member).exists():
return Response(
{"detail": "User is already a member of this group."},
status=status.HTTP_409_CONFLICT,
)
membership = ReadingGroupMembership.objects.create(
user=new_member, group=group, role=ReadingGroupMembership.Role.MEMBER
)
from apps.groups.serializers import ReadingGroupMembershipSerializer
return Response(
ReadingGroupMembershipSerializer(membership).data,
status=status.HTTP_201_CREATED,
) )
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin]) def perform_create(self, serializer) -> None:
def remove_member(self, request: Request, pk: int | None = None) -> Response: group = serializer.save(created_by=self.request.user)
group = self.get_object() GroupMembership.objects.create(
member_serializer = AddMemberSerializer(data=request.data) user=self.request.user,
member_serializer.is_valid(raise_exception=True)
user_id = member_serializer.validated_data["user_id"]
if user_id == group.admin_id:
return Response(
{"detail": "Cannot remove the group admin."},
status=status.HTTP_400_BAD_REQUEST,
)
deleted, _ = ReadingGroupMembership.objects.filter(
group=group, user_id=user_id
).delete()
if not deleted:
return Response(
{"detail": "User is not a member of this group."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class GroupBookViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
def get_serializer_class(self):
if self.action == "create":
return GroupBookCreateSerializer
if self.action == "retrieve":
return GroupBookDetailSerializer
return GroupBookListSerializer
def get_queryset(self) -> QuerySet[GroupBook]:
group_id = self.kwargs.get("group_pk")
return GroupBook.objects.filter(
group_id=group_id
).select_related("ebook", "ebook__user", "uploaded_by").prefetch_related(
"sections", "schedules"
)
def perform_create(self, serializer: GroupBookCreateSerializer) -> GroupBook:
group = get_object_or_404(ReadingGroup, id=self.kwargs["group_pk"])
ebook = get_object_or_404(EBook, id=serializer.validated_data["ebook_id"])
title = serializer.validated_data.get("title") or ebook.title
# Mark existing active books as replaced
GroupBook.objects.filter(group=group, status=GroupBook.Status.ACTIVE).update(
status=GroupBook.Status.REPLACED
)
group_book = GroupBook.objects.create(
group=group, group=group,
ebook=ebook, role="admin",
uploaded_by=self.request.user,
title=title,
status=GroupBook.Status.ACTIVE,
) )
# Auto-detect sections @action(detail=False, methods=["get"], url_path="all")
try: def all_groups(self, request: Request) -> Response:
apply_sections(group_book) """Return all groups (for discovery)."""
except Exception: qs = ReadingGroup.objects.all().prefetch_related("memberships", "meetings")
logger.exception("Auto-section detection failed for group_book %s", group_book.id) page = self.paginate_queryset(qs)
if page is not None:
# Auto-generate schedule serializer = self.get_serializer(page, many=True)
try: return self.get_paginated_response(serializer.data)
apply_schedule(group_book) serializer = self.get_serializer(qs, many=True)
except Exception:
logger.exception("Schedule generation failed for group_book %s", group_book.id)
return group_book
@action(detail=True, methods=["post"], url_path="detect-sections")
def detect_sections_action(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
try:
sections = apply_sections(group_book)
except Exception as exc:
logger.exception("Section detection failed for group_book %s", group_book.id)
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(SectionSerializer(sections, many=True).data)
@action(detail=True, methods=["post"], url_path="adjust-sections")
def adjust_sections(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
adjust_serializer = SectionAdjustSerializer(data=request.data)
adjust_serializer.is_valid(raise_exception=True)
operation = adjust_serializer.validated_data["operation"]
section_ids: list[int] = adjust_serializer.validated_data["section_ids"]
if operation == "merge":
if len(section_ids) < 2:
return Response(
{"error": "At least 2 section IDs required for merge."},
status=status.HTTP_400_BAD_REQUEST,
)
sections = list(
Section.objects.filter(id__in=section_ids, group_book=group_book).order_by("order")
)
if len(sections) < 2:
return Response(
{"error": "Not enough valid sections found."},
status=status.HTTP_400_BAD_REQUEST,
)
merged_title = "".join(s.title for s in sections)
merged_minutes = sum(s.estimated_reading_minutes for s in sections)
merged_start = sections[0].start_chapter_index
merged_end = sections[-1].end_chapter_index
merged_order = sections[0].order
# Delete old sections
Section.objects.filter(id__in=section_ids, group_book=group_book).delete()
# Create merged section
merged = Section.objects.create(
group_book=group_book,
title=merged_title,
order=merged_order,
start_chapter_index=merged_start,
end_chapter_index=merged_end,
estimated_reading_minutes=merged_minutes,
)
# Reorder remaining sections
_renumber_sections(group_book)
return Response(SectionSerializer(merged).data)
if operation == "split":
if len(section_ids) != 1:
return Response(
{"error": "Exactly 1 section ID required for split."},
status=status.HTTP_400_BAD_REQUEST,
)
split_at = adjust_serializer.validated_data.get("split_at", 2)
section = get_object_or_404(Section, id=section_ids[0], group_book=group_book)
chapters = section.end_chapter_index - section.start_chapter_index
if chapters < split_at:
return Response(
{"error": f"Section only has {chapters} chapters, cannot split into {split_at}."},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete original
orig_order = section.order
orig_title = section.title
section.delete()
# Create split sections
chunk_size = max(1, chapters // split_at)
new_sections: list[Section] = []
for i in range(split_at):
start_idx = section.start_chapter_index + i * chunk_size
end_idx = start_idx + chunk_size if i < split_at - 1 else section.end_chapter_index
minutes_each = max(1, section.estimated_reading_minutes // split_at)
new_sec = Section.objects.create(
group_book=group_book,
title=f"{orig_title} (Part {i + 1})",
order=orig_order + i,
start_chapter_index=start_idx,
end_chapter_index=end_idx,
estimated_reading_minutes=minutes_each,
)
new_sections.append(new_sec)
_renumber_sections(group_book)
return Response(SectionSerializer(new_sections, many=True).data)
return Response({"error": "Invalid operation."}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["get", "post", "delete"], url_path="schedule")
def schedule(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
if request.method == "GET":
schedules = ReadingSchedule.objects.filter(group_book=group_book).order_by("meeting_number")
return Response(ReadingScheduleSerializer(schedules, many=True).data)
if request.method == "POST":
ReadingSchedule.objects.filter(group_book=group_book).delete()
num_meetings = int(request.data.get("num_meetings", 4))
try:
schedules = apply_schedule(group_book, num_meetings=num_meetings)
except Exception as exc:
logger.exception("Schedule generation failed for group_book %s", group_book.id)
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
ReadingScheduleSerializer(schedules, many=True).data,
status=status.HTTP_201_CREATED,
)
if request.method == "DELETE":
ReadingSchedule.objects.filter(group_book=group_book).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "patch"], url_path="progress")
def progress(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
if request.method == "GET":
progress_records = MemberProgress.objects.filter(
group_book=group_book
).select_related("user", "current_section")
return Response(MemberProgressSerializer(progress_records, many=True).data)
# PATCH: update own progress
progress_obj, _created = MemberProgress.objects.get_or_create(
user=request.user, group_book=group_book,
)
serializer = MemberProgressSerializer(progress_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data) return Response(serializer.data)
def _renumber_sections(group_book: GroupBook) -> None: class GroupMeetingViewSet(viewsets.ModelViewSet):
"""Re-number sections sequentially by their current order.""" permission_classes = [IsAuthenticated]
sections = list(Section.objects.filter(group_book=group_book).order_by("order")) serializer_class = GroupMeetingSerializer
for idx, section in enumerate(sections):
if section.order != idx + 1: def get_queryset(self) -> QuerySet[GroupMeeting]:
section.order = idx + 1 user_group_ids = (
section.save(update_fields=["order"]) GroupMembership.objects.filter(user=self.request.user)
.values_list("group_id", flat=True)
)
return GroupMeeting.objects.filter(group_id__in=user_group_ids).select_related(
"group"
)
def perform_create(self, serializer) -> None:
group = serializer.validated_data["group"]
if not group.memberships.filter(user=self.request.user).exists():
raise permissions.PermissionDenied("You must be a group member to create meetings.")
serializer.save()
@action(detail=False, methods=["get"], url_path="upcoming")
def upcoming(self, request: Request) -> Response:
"""Return upcoming meetings for the user's groups (within 3 days)."""
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
three_days = now + timedelta(days=3)
qs = self.get_queryset().filter(
scheduled_at__gte=now,
scheduled_at__lte=three_days,
)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def notification_preferences_view(request: Request) -> Response:
"""Get or update notification preferences for the current user."""
pref, _created = NotificationPreference.objects.get_or_create(user=request.user)
if request.method == "GET":
serializer = NotificationPreferenceSerializer(pref)
return Response(serializer.data)
serializer = NotificationPreferenceSerializer(pref, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def pace_status_view(request: Request) -> Response:
"""Get reading pace status for the current user's upcoming meetings."""
ebook_id = request.query_params.get("ebook_id")
ebook: EBook | None = None
if ebook_id:
try:
ebook = EBook.objects.select_related("reading_progress").prefetch_related("chapters").get(
pk=ebook_id, user=request.user
)
except EBook.DoesNotExist:
return Response(
{"error": "EBook not found."}, status=status.HTTP_404_NOT_FOUND
)
pace_data = calculate_pace(request.user, ebook=ebook)
serializer = PaceStatusSerializer(pace_data, many=True)
return Response(serializer.data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def dismiss_pace_notification_view(request: Request) -> Response:
"""Dismiss a pace notification so it won't show again."""
meeting_id = request.data.get("meeting_id")
ebook_id = request.data.get("ebook_id")
if not meeting_id or not ebook_id:
return Response(
{"error": "meeting_id and ebook_id are required."},
status=status.HTTP_400_BAD_REQUEST,
)
PaceNotification.objects.filter(
user=request.user,
meeting_id=meeting_id,
ebook_id=ebook_id,
status=NotificationStatus.PENDING,
).update(status=NotificationStatus.DISMISSED)
return Response({"status": "dismissed"})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def reminder_view(request: Request) -> Response:
"""Get pre-meeting reminders for meetings within 24 hours."""
pref, _ = NotificationPreference.objects.get_or_create(user=request.user)
if not pref.reminder_enabled:
return Response([])
meetings = get_reminder_meetings(request.user)
now_dt = __import__("django").utils.timezone.now()
reminders = []
for mtg in meetings:
group_ebook = mtg.group.ebook
ebook: EBook | None = group_ebook
if not ebook:
ebooks = list(
EBook.objects.filter(reading_groups=mtg.group).select_related("reading_progress")[:1]
)
ebook = ebooks[0] if ebooks else None
if not ebook:
continue
hours_until = max(0, int((mtg.scheduled_at - now_dt).total_seconds() / 3600))
reminders.append({
"meeting_id": mtg.pk,
"meeting_title": mtg.title or mtg.target_section_label,
"scheduled_at": mtg.scheduled_at.isoformat(),
"target_section": mtg.target_section,
"target_section_label": mtg.target_section_label,
"group_name": mtg.group.name,
"hours_until": hours_until,
"ebook_id": ebook.pk,
"ebook_title": ebook.title,
})
serializer = ReminderSerializer(reminders, many=True)
return Response(serializer.data)
+1 -1
View File
@@ -39,9 +39,9 @@ INSTALLED_APPS = [
# Local apps # Local apps
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.groups",
"apps.annotations", "apps.annotations",
"apps.reader", "apps.reader",
"apps.groups",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+1 -1
View File
@@ -6,9 +6,9 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/auth/", include("apps.users.urls")), path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.urls")),
path("api/groups/", include("apps.groups.urls")),
path("api/annotations/", include("apps.annotations.urls")), path("api/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")), path("api/reader/", include("apps.reader.urls")),
path("api/groups/", include("apps.groups.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:
-6
View File
@@ -12,9 +12,6 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default:
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
const GroupsPage = lazy(() => import("./pages/GroupsPage").then((m) => ({ default: m.GroupsPage })));
const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage })));
const GroupBookPage = lazy(() => import("./pages/GroupBookPage").then((m) => ({ default: m.GroupBookPage })));
const AuthPage = lazy(() => import("./pages/AuthPage")); const AuthPage = lazy(() => import("./pages/AuthPage"));
@@ -47,9 +44,6 @@ function AppRoutes() {
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} /> <Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} /> <Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/groups" element={<ProtectedRoute><GroupsPage /></ProtectedRoute>} />
<Route path="/groups/:groupId" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
<Route path="/groups/:groupId/books/:bookId" element={<ProtectedRoute><GroupBookPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+42 -136
View File
@@ -1,151 +1,57 @@
import api from "./client"; import api from "./client";
import type { import type {
AddMemberPayload, GroupMeeting,
AdjustSectionsPayload, MeetingReminder,
CreateGroupBookPayload, NotificationPreferences,
CreateGroupPayload, PaceStatus,
GroupBook,
GroupBookDetail,
MemberProgress,
ReadingGroup, ReadingGroup,
ReadingGroupDetail, } from "../types/notifications";
ReadingSchedule,
Section,
} from "../types/group";
export const groupsApi = { export const groupsApi = {
// ---- Reading Groups ---- async getPaceStatus(ebookId?: number): Promise<PaceStatus[]> {
const params: Record<string, string> = {};
if (ebookId) params.ebook_id = String(ebookId);
const { data } = await api.get<PaceStatus[]>("/groups/notifications/pace/", { params });
return data;
},
async listGroups(): Promise<ReadingGroup[]> { async getPreferences(): Promise<NotificationPreferences> {
const { data } = await api.get<NotificationPreferences>("/groups/notifications/preferences/");
return data;
},
async updatePreferences(
prefs: Partial<NotificationPreferences>,
): Promise<NotificationPreferences> {
const { data } = await api.patch<NotificationPreferences>(
"/groups/notifications/preferences/",
prefs,
);
return data;
},
async dismissPaceNotification(meetingId: number, ebookId: number): Promise<void> {
await api.post("/groups/notifications/dismiss/", {
meeting_id: meetingId,
ebook_id: ebookId,
});
},
async getReminders(): Promise<MeetingReminder[]> {
const { data } = await api.get<MeetingReminder[]>("/groups/notifications/reminders/");
return data;
},
async getGroups(): Promise<ReadingGroup[]> {
const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>( const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>(
"/groups/" "/groups/groups/",
); );
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return data.results ?? []; return data.results ?? [];
}, },
async getGroup(id: number): Promise<ReadingGroupDetail> { async getUpcomingMeetings(): Promise<GroupMeeting[]> {
const { data } = await api.get<ReadingGroupDetail>(`/groups/${id}/`); const { data } = await api.get<GroupMeeting[]>("/groups/meetings/upcoming/");
return data;
},
async createGroup(payload: CreateGroupPayload): Promise<ReadingGroup> {
const { data } = await api.post<ReadingGroup>("/groups/", payload);
return data;
},
async deleteGroup(id: number): Promise<void> {
await api.delete(`/groups/${id}/`);
},
async addMember(groupId: number, payload: AddMemberPayload): Promise<void> {
await api.post(`/groups/${groupId}/add_member/`, payload);
},
async removeMember(groupId: number, payload: AddMemberPayload): Promise<void> {
await api.post(`/groups/${groupId}/remove_member/`, payload);
},
// ---- Group Books ----
async listGroupBooks(groupId: number): Promise<GroupBook[]> {
const { data } = await api.get<GroupBook[]>(`/groups/${groupId}/books/`);
return Array.isArray(data) ? data : (data as { results: GroupBook[] }).results ?? [];
},
async getGroupBook(groupId: number, bookId: number): Promise<GroupBookDetail> {
const { data } = await api.get<GroupBookDetail>(`/groups/${groupId}/books/${bookId}/`);
return data;
},
async createGroupBook(
groupId: number,
payload: CreateGroupBookPayload
): Promise<GroupBookDetail> {
const { data } = await api.post<GroupBookDetail>(
`/groups/${groupId}/books/`,
payload
);
return data;
},
async deleteGroupBook(groupId: number, bookId: number): Promise<void> {
await api.delete(`/groups/${groupId}/books/${bookId}/`);
},
// ---- Sections ----
async detectSections(
groupId: number,
bookId: number
): Promise<Section[]> {
const { data } = await api.post<Section[]>(
`/groups/${groupId}/books/${bookId}/detect-sections/`
);
return data;
},
async adjustSections(
groupId: number,
bookId: number,
payload: AdjustSectionsPayload
): Promise<Section | Section[]> {
const { data } = await api.post<Section | Section[]>(
`/groups/${groupId}/books/${bookId}/adjust-sections/`,
payload
);
return data;
},
// ---- Schedule ----
async getSchedule(
groupId: number,
bookId: number
): Promise<ReadingSchedule[]> {
const { data } = await api.get<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`
);
return data;
},
async generateSchedule(
groupId: number,
bookId: number,
numMeetings: number = 4
): Promise<ReadingSchedule[]> {
const { data } = await api.post<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`,
{ num_meetings: numMeetings }
);
return data;
},
async deleteSchedule(groupId: number, bookId: number): Promise<void> {
await api.delete(`/groups/${groupId}/books/${bookId}/schedule/`);
},
// ---- Progress ----
async getProgress(
groupId: number,
bookId: number
): Promise<MemberProgress[]> {
const { data } = await api.get<MemberProgress[]>(
`/groups/${groupId}/books/${bookId}/progress/`
);
return data;
},
async updateProgress(
groupId: number,
bookId: number,
payload: { current_section?: number; completed_sections?: number[] }
): Promise<MemberProgress> {
const { data } = await api.patch<MemberProgress>(
`/groups/${groupId}/books/${bookId}/progress/`,
payload
);
return data; return data;
}, },
}; };
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { groupsApi } from "../api/groups";
import type { PaceStatus } from "../types/notifications";
interface PaceNotificationProps {
ebookId?: number;
}
export function PaceNotification({ ebookId }: PaceNotificationProps) {
const { t } = useTranslation();
const [paceItems, setPaceItems] = useState<PaceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
useEffect(() => {
const load = async () => {
try {
const data = await groupsApi.getPaceStatus(ebookId);
setPaceItems(data);
} catch {
// Silently fail — notifications are non-critical
} finally {
setLoading(false);
}
};
void load();
}, [ebookId]);
const handleDismiss = async (meetingId: number, itemEbookId: number) => {
const key = `${meetingId}-${itemEbookId}`;
setDismissed((prev) => new Set(prev).add(key));
try {
await groupsApi.dismissPaceNotification(meetingId, itemEbookId);
} catch {
// Best effort dismissal
}
};
if (loading || paceItems.length === 0) return null;
const visibleItems = paceItems.filter(
(p) => !dismissed.has(`${p.meeting_id}-${p.ebook_id}`),
);
if (visibleItems.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "12px 0" }}>
{visibleItems.map((item) => {
const isBehind = item.status === "behind";
const isAhead = item.status === "ahead" || item.status === "completed";
const isOnTrack = item.status === "on_track";
const bgColor = isBehind ? "#fde8e8" : isAhead ? "#d4edda" : "#e8f4fd";
const borderColor = isBehind ? "#e74c3c" : isAhead ? "#27ae60" : "#3498db";
const textColor = isBehind ? "#a71d2a" : isAhead ? "#155724" : "#0c5460";
return (
<div
key={`${item.meeting_id}-${item.ebook_id}`}
style={{
background: bgColor,
border: `1px solid ${borderColor}`,
borderRadius: 8,
padding: "12px 16px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: textColor, marginBottom: 4 }}>
{isBehind
? `Behind schedule — catch up ${item.sections_to_catch_up} section${item.sections_to_catch_up !== 1 ? "s" : ""}`
: isOnTrack
? "On track — keep going!"
: item.status === "completed"
? "Meeting target completed!"
: "Ahead of schedule — great job!"}
</div>
<div style={{ fontSize: 13, color: textColor, opacity: 0.85 }}>
{item.meeting_title || `Target: ${item.target_section_label || `Section ${item.target_section}`}`}
{" — "}
{item.ebook_title}
</div>
<div style={{ fontSize: 12, color: textColor, opacity: 0.7, marginTop: 2 }}>
{isBehind
? `Currently at section ${item.current_section}, need section ${item.target_section}`
: `Section ${item.current_section} of ${item.target_section}`}
{item.days_until_meeting > 0 &&
` · ${item.days_until_meeting} day${item.days_until_meeting !== 1 ? "s" : ""} until meeting`}
{item.days_until_meeting === 0 && " · Meeting today"}
</div>
</div>
<button
type="button"
onClick={() => handleDismiss(item.meeting_id, item.ebook_id)}
style={{
background: "none",
border: "none",
color: textColor,
fontSize: 20,
cursor: "pointer",
padding: "0 4px",
lineHeight: 1,
opacity: 0.6,
flexShrink: 0,
}}
title="Dismiss"
>
×
</button>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { groupsApi } from "../../api/groups";
import type { PaceStatus } from "../../types/notifications";
interface ReadingPaceBannerProps {
ebookId: number;
currentSection: number;
}
export function ReadingPaceBanner({ ebookId, currentSection }: ReadingPaceBannerProps) {
const [paceItems, setPaceItems] = useState<PaceStatus[]>([]);
useEffect(() => {
const load = async () => {
try {
const data = await groupsApi.getPaceStatus(ebookId);
setPaceItems(data);
} catch {
// Silently fail
}
};
void load();
}, [ebookId, currentSection]);
if (paceItems.length === 0) return null;
// Only show if user is one section away from target
const relevantItems = paceItems.filter(
(p) => Math.abs(p.target_section - p.current_section) <= 1 && p.status !== "completed",
);
if (relevantItems.length === 0) return null;
return (
<div
style={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#fff",
padding: "10px 16px",
fontSize: 13,
fontWeight: 500,
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
flexWrap: "wrap",
position: "sticky",
top: 0,
zIndex: 10,
}}
>
{relevantItems.map((item) => {
const isAlmostThere = item.sections_to_catch_up === 1;
const isAtTarget = item.sections_to_catch_up === 0;
return (
<span key={item.meeting_id}>
{isAtTarget
? `You've reached the target for ${item.meeting_title || `Section ${item.target_section}`}!`
: isAlmostThere
? `Almost there! Just 1 section to go for ${item.meeting_title || `Section ${item.target_section}`}.`
: `You should be on ${item.target_section_label || `Section ${item.target_section}`} by the upcoming meeting.`}
{item.days_until_meeting > 0 &&
` (${item.days_until_meeting} day${item.days_until_meeting !== 1 ? "s" : ""} left)`}
</span>
);
})}
</div>
);
}
@@ -22,6 +22,7 @@ import { SelectionPopover } from "./SelectionPopover";
import { BookMarkersPanel } from "./BookMarkersPanel"; import { BookMarkersPanel } from "./BookMarkersPanel";
import { BookmarkReaderRail } from "./BookmarkReaderRail"; import { BookmarkReaderRail } from "./BookmarkReaderRail";
import { ResumeReadingButton } from "./ResumeReadingButton"; import { ResumeReadingButton } from "./ResumeReadingButton";
import { ReadingPaceBanner } from "../notifications/ReadingPaceBanner";
import type { MarkerEntry } from "@/types"; import type { MarkerEntry } from "@/types";
const ReaderToolbar = lazy(() => import("./ReaderToolbar")); const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
@@ -130,6 +131,10 @@ export function EpubReadingView({ book, bookId, initialEpubLocation }: EpubReadi
return ( return (
<ReaderSuspenseShell theme={settings.theme}> <ReaderSuspenseShell theme={settings.theme}>
<ReadingPaceBanner
ebookId={bookId}
currentSection={tocItems.length > 0 ? Math.max(1, Math.ceil((progress / 100) * tocItems.length)) : 0}
/>
<ReaderToolbar <ReaderToolbar
bookTitle={book.title} bookTitle={book.title}
chapterTitle={chapterTitle || t("reader.reading")} chapterTitle={chapterTitle || t("reader.reading")}
@@ -24,6 +24,7 @@ import { BookmarkReaderRail } from "./BookmarkReaderRail";
import { ResumeReadingButton } from "./ResumeReadingButton"; import { ResumeReadingButton } from "./ResumeReadingButton";
import { PdfLimitationsNotice } from "./PdfLimitationsNotice"; import { PdfLimitationsNotice } from "./PdfLimitationsNotice";
import { PdfViewer } from "./PdfViewer"; import { PdfViewer } from "./PdfViewer";
import { ReadingPaceBanner } from "../notifications/ReadingPaceBanner";
import type { MarkerEntry } from "@/types"; import type { MarkerEntry } from "@/types";
const ReaderToolbar = lazy(() => import("./ReaderToolbar")); const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
@@ -170,6 +171,10 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
return ( return (
<ReaderSuspenseShell theme={settings.theme}> <ReaderSuspenseShell theme={settings.theme}>
<ReadingPaceBanner
ebookId={bookId}
currentSection={currentPage}
/>
<ReaderToolbar <ReaderToolbar
bookTitle={book.title} bookTitle={book.title}
chapterTitle={chapterTitle || t("reader.reading")} chapterTitle={chapterTitle || t("reader.reading")}
-48
View File
@@ -245,54 +245,6 @@ const enUS = {
home: "Home", home: "Home",
bookmarksNotes: "Bookmarks & Notes", bookmarksNotes: "Bookmarks & Notes",
}, },
groups: {
title: "Reading Groups",
createGroup: "Create Group",
create: "Create",
cancel: "Cancel",
groupName: "Group Name",
groupNamePlaceholder: "Enter group name",
description: "Description",
descriptionPlaceholder: "What is this group about?",
optional: "Optional",
noGroups: "No groups yet",
noGroupsHint: "Create a reading group to start reading together!",
members: "Members",
admin: "Admin",
addMember: "Add Member",
memberIdPlaceholder: "Enter user ID",
add: "Add",
remove: "Remove",
books: "Group Books",
uploadEpub: "Upload EPUB",
selectEbook: "Select an EPUB to share with the group",
noEpubBooks: "No EPUB files uploaded yet",
uploadFirst: "Upload an EPUB first",
confirmUpload: "Confirm Upload",
noBooks: "No books in this group yet",
sections: "Sections",
schedule: "Schedule",
progress: "Progress",
detectSections: "Auto-Detect Sections",
mergeSections: "Merge Sections",
selectSectionsHint: "Click sections to select for merging",
merge: "Merge",
clear: "Clear",
split: "Split",
splitInto: "Split into",
parts: "parts",
splitBtn: "Split",
noSections: "No sections detected yet",
noSectionsHint: 'Click "Auto-Detect Sections" to parse the EPUB chapters into reading sections.',
readingSchedule: "Reading Schedule",
generateSchedule: "Generate Schedule",
noSchedule: "No schedule generated yet",
noScheduleHint: "Detect sections first, then generate a reading schedule for your group.",
meeting: "Meeting",
memberProgress: "Member Progress",
noProgress: "No progress data yet",
noProgressHint: "Members will show progress once they start reading.",
},
} as const; } as const;
export default enUS; export default enUS;
-48
View File
@@ -247,54 +247,6 @@ const esES: Locale = {
home: "Inicio", home: "Inicio",
bookmarksNotes: "Marcadores y notas", bookmarksNotes: "Marcadores y notas",
}, },
groups: {
title: "Grupos de Lectura",
createGroup: "Crear Grupo",
create: "Crear",
cancel: "Cancelar",
groupName: "Nombre del Grupo",
groupNamePlaceholder: "Ingresa el nombre del grupo",
description: "Descripción",
descriptionPlaceholder: "¿De qué trata este grupo?",
optional: "Opcional",
noGroups: "Aún no hay grupos",
noGroupsHint: "¡Crea un grupo de lectura para leer juntos!",
members: "Miembros",
admin: "Admin",
addMember: "Agregar Miembro",
memberIdPlaceholder: "Ingresa ID de usuario",
add: "Agregar",
remove: "Eliminar",
books: "Libros del Grupo",
uploadEpub: "Subir EPUB",
selectEbook: "Selecciona un EPUB para compartir con el grupo",
noEpubBooks: "No hay archivos EPUB subidos aún",
uploadFirst: "Sube un EPUB primero",
confirmUpload: "Confirmar Subida",
noBooks: "No hay libros en este grupo aún",
sections: "Secciones",
schedule: "Calendario",
progress: "Progreso",
detectSections: "Auto-Detectar Secciones",
mergeSections: "Fusionar Secciones",
selectSectionsHint: "Haz clic en las secciones para seleccionar y fusionar",
merge: "Fusionar",
clear: "Limpiar",
split: "Dividir",
splitInto: "Dividir en",
parts: "partes",
splitBtn: "Dividir",
noSections: "No se detectaron secciones",
noSectionsHint: 'Haz clic en "Auto-Detectar Secciones" para analizar los capítulos del EPUB.',
readingSchedule: "Calendario de Lectura",
generateSchedule: "Generar Calendario",
noSchedule: "No hay calendario generado",
noScheduleHint: "Detecta secciones primero, luego genera un calendario de lectura para tu grupo.",
meeting: "Reunión",
memberProgress: "Progreso de Miembros",
noProgress: "Sin datos de progreso",
noProgressHint: "Los miembros mostrarán progreso cuando comiencen a leer.",
},
}; };
export default esES; export default esES;
-426
View File
@@ -1,426 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { groupsApi } from "../api/groups";
import { getApiErrorMessage } from "../api/errors";
import type {
GroupBookDetail,
ReadingSchedule,
Section,
MemberProgress,
} from "../types/group";
export function GroupBookPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { groupId, bookId } = useParams<{ groupId: string; bookId: string }>();
const [book, setBook] = useState<GroupBookDetail | null>(null);
const [sections, setSections] = useState<Section[]>([]);
const [schedules, setSchedules] = useState<ReadingSchedule[]>([]);
const [progress, setProgress] = useState<MemberProgress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"sections" | "schedule" | "progress">("sections");
// Merge state
const [selectedForMerge, setSelectedForMerge] = useState<number[]>([]);
const [splitSectionId, setSplitSectionId] = useState<number | null>(null);
const [splitAt, setSplitAt] = useState(2);
const gId = Number(groupId);
const bId = Number(bookId);
const loadData = useCallback(async () => {
if (!gId || !bId) return;
setLoading(true);
try {
const [bookData, schedData, progData] = await Promise.all([
groupsApi.getGroupBook(gId, bId),
groupsApi.getSchedule(gId, bId).catch(() => [] as ReadingSchedule[]),
groupsApi.getProgress(gId, bId).catch(() => [] as MemberProgress[]),
]);
setBook(bookData);
setSections(bookData.sections ?? []);
setSchedules(schedData);
setProgress(progData);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to load book"));
} finally {
setLoading(false);
}
}, [gId, bId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleDetectSections = async () => {
if (!gId || !bId) return;
try {
const newSections = await groupsApi.detectSections(gId, bId);
setSections(newSections);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to detect sections"));
}
};
const handleMerge = async () => {
if (selectedForMerge.length < 2 || !gId || !bId) return;
try {
await groupsApi.adjustSections(gId, bId, {
operation: "merge",
section_ids: selectedForMerge,
});
setSelectedForMerge([]);
await loadData();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to merge sections"));
}
};
const handleSplit = async () => {
if (!splitSectionId || !gId || !bId) return;
try {
await groupsApi.adjustSections(gId, bId, {
operation: "split",
section_ids: [splitSectionId],
split_at: splitAt,
});
setSplitSectionId(null);
await loadData();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to split section"));
}
};
const handleGenerateSchedule = async () => {
if (!gId || !bId) return;
try {
const newSched = await groupsApi.generateSchedule(gId, bId, 4);
setSchedules(newSched);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to generate schedule"));
}
};
const toggleMergeSelect = (id: number) => {
setSelectedForMerge((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]
);
};
const formatMinutes = (mins: number): string => {
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
};
if (loading) {
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p>
</div>
);
}
if (!book) {
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#e74c3c", padding: 40 }}>Book not found</p>
<button onClick={() => navigate(`/groups/${groupId}`)} style={{ display: "block", margin: "0 auto" }}> Back</button>
</div>
);
}
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
{/* Header */}
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{book.title}</h1>
<p style={{ margin: 4, fontSize: 13, color: "#888" }}>
{book.ebook.author && `by ${book.ebook.author} · `}
{book.ebook.page_count} chapters · {book.ebook.format.toUpperCase()}
</p>
</div>
<button onClick={() => navigate(`/groups/${groupId}`)} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}>
{t("common.back")}
</button>
</header>
{error && (
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}>
{error}
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button>
</div>
)}
{/* Tabs */}
<div style={{ display: "flex", gap: 4, marginBottom: 20, background: "#eee", borderRadius: 8, padding: 4 }}>
{(["sections", "schedule", "progress"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1, padding: "10px 16px", borderRadius: 6, border: "none",
fontSize: 14, fontWeight: 600, cursor: "pointer",
background: tab === t ? "#fff" : "transparent",
color: tab === t ? "#1a1a2e" : "#888",
boxShadow: tab === t ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
}}
>
{t === "sections" ? t("groups.sections") : t === "schedule" ? t("groups.schedule") : t("groups.progress")}
</button>
))}
</div>
{/* Sections Tab */}
{tab === "sections" && (
<section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>
{sections.length} {t("groups.sections")}
</h2>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={handleDetectSections}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.detectSections")}
</button>
</div>
</div>
{/* Merge controls */}
<div style={{ background: "#fff", padding: 16, borderRadius: 10, marginBottom: 12, boxShadow: "0 1px 4px rgba(0,0,0,0.06)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.mergeSections")}:</span>
<span style={{ fontSize: 13, color: "#888" }}>
{selectedForMerge.length === 0
? t("groups.selectSectionsHint")
: `${selectedForMerge.length} selected`}
</span>
{selectedForMerge.length >= 2 && (
<button
onClick={handleMerge}
style={{ padding: "6px 14px", borderRadius: 6, border: "none", background: "#27ae60", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.merge")}
</button>
)}
{selectedForMerge.length > 0 && (
<button
onClick={() => setSelectedForMerge([])}
style={{ padding: "6px 12px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", fontSize: 13, cursor: "pointer" }}
>
{t("groups.clear")}
</button>
)}
</div>
</div>
{sections.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noSections")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noSectionsHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{sections.map((section) => (
<div
key={section.id}
style={{
background: selectedForMerge.includes(section.id) ? "#e8f0fe" : "#fff",
border: selectedForMerge.includes(section.id) ? "2px solid #1a1a2e" : "1px solid #eee",
padding: 16, borderRadius: 10, cursor: "pointer",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
transition: "all 0.15s",
}}
onClick={() => toggleMergeSelect(section.id)}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{
background: "#1a1a2e", color: "#fff", borderRadius: 20,
width: 26, height: 26, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 13, fontWeight: 700, flexShrink: 0,
}}>
{section.order}
</span>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: "#1a1a2e" }}>{section.title}</h3>
</div>
<p style={{ margin: "6px 0 0 34px", fontSize: 13, color: "#888" }}>
Ch. {section.start_chapter_index}{section.end_chapter_index - 1} · ~{formatMinutes(section.estimated_reading_minutes)} reading time
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setSplitSectionId(splitSectionId === section.id ? null : section.id);
}}
style={{
padding: "4px 10px", borderRadius: 4, border: "1px solid #ddd",
background: splitSectionId === section.id ? "#1a1a2e" : "#fff",
color: splitSectionId === section.id ? "#fff" : "#666",
fontSize: 12, cursor: "pointer", flexShrink: 0,
}}
>
{t("groups.split")}
</button>
</div>
{splitSectionId === section.id && (
<div
style={{ marginTop: 10, padding: 10, background: "#f8f9fa", borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ fontSize: 13 }}>{t("groups.splitInto")}</span>
<input
type="number"
value={splitAt}
min={2}
max={10}
onChange={(e) => setSplitAt(Number(e.target.value))}
style={{ width: 50, padding: "4px 8px", borderRadius: 4, border: "1px solid #ddd", fontSize: 13, textAlign: "center" }}
/>
<span style={{ fontSize: 13 }}>{t("groups.parts")}</span>
<button
onClick={handleSplit}
style={{ padding: "4px 12px", borderRadius: 4, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 12, cursor: "pointer" }}
>
{t("groups.splitBtn")}
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
)}
{/* Schedule Tab */}
{tab === "schedule" && (
<section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>
{t("groups.readingSchedule")}
</h2>
<button
onClick={handleGenerateSchedule}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.generateSchedule")}
</button>
</div>
{schedules.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noSchedule")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noScheduleHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{schedules.map((s) => (
<div
key={s.id}
style={{ background: "#fff", padding: 20, borderRadius: 12, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: "#1a1a2e" }}>
{t("groups.meeting")} {s.meeting_number}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>
Week of {new Date(s.week_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<span style={{
background: "#f0f0f0", padding: "6px 12px", borderRadius: 20,
fontSize: 13, fontWeight: 600, color: "#555",
}}>
{s.section_details.reduce((sum, sec) => sum + sec.estimated_reading_minutes, 0) > 0
? `~${formatMinutes(s.section_details.reduce((sum, sec) => sum + sec.estimated_reading_minutes, 0))}`
: ""}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{s.section_details.map((sec) => (
<div
key={sec.id}
style={{
display: "flex", alignItems: "center", gap: 10, padding: "8px 12px",
background: "#f8f9fa", borderRadius: 6, fontSize: 14,
}}
>
<span style={{
background: "#1a1a2e", color: "#fff", borderRadius: 12,
width: 22, height: 22, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>
{sec.order}
</span>
<span style={{ flex: 1 }}>{sec.title}</span>
<span style={{ fontSize: 12, color: "#999" }}>{formatMinutes(sec.estimated_reading_minutes)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Progress Tab */}
{tab === "progress" && (
<section>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: "0 0 16px" }}>
{t("groups.memberProgress")}
</h2>
{progress.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noProgress")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noProgressHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{progress.map((p) => {
const totalSections = sections.length;
const completed = p.completed_sections.length;
const pct = totalSections > 0 ? Math.round((completed / totalSections) * 100) : 0;
return (
<div
key={p.id}
style={{ background: "#fff", padding: 16, borderRadius: 10, boxShadow: "0 1px 4px rgba(0,0,0,0.06)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<span style={{ fontSize: 15, fontWeight: 600 }}>{p.user_email}</span>
<span style={{ fontSize: 13, color: "#888" }}>{completed}/{totalSections} · {pct}%</span>
</div>
<div style={{ height: 6, background: "#eee", borderRadius: 3, overflow: "hidden" }}>
<div
style={{
height: "100%", borderRadius: 3,
background: "linear-gradient(90deg, #27ae60, #2ecc71)",
width: `${pct}%`, transition: "width 0.3s",
}}
/>
</div>
{p.current_section_title && (
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#888" }}>
Currently: {p.current_section_title}
</p>
)}
</div>
);
})}
</div>
)}
</section>
)}
</div>
);
}
-300
View File
@@ -1,300 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { groupsApi } from "../api/groups";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
import type { ReadingGroupDetail, GroupBook, GroupBookDetail } from "../types/group";
import type { EBookListItem } from "../types/book";
export function GroupDetailPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { groupId } = useParams<{ groupId: string }>();
const [group, setGroup] = useState<ReadingGroupDetail | null>(null);
const [books, setBooks] = useState<GroupBook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Upload state
const [showUpload, setShowUpload] = useState(false);
const [ebooks, setEbooks] = useState<EBookListItem[]>([]);
const [selectedEbookId, setSelectedEbookId] = useState<number | null>(null);
const [uploading, setUploading] = useState(false);
// Add member state
const [showAddMember, setShowAddMember] = useState(false);
const [memberEmail, setMemberEmail] = useState("");
const [adding, setAdding] = useState(false);
const id = Number(groupId);
const loadData = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [groupData, bookData] = await Promise.all([
groupsApi.getGroup(id),
groupsApi.listGroupBooks(id),
]);
setGroup(groupData);
setBooks(bookData);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to load group"));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
const handleUploadClick = async () => {
setShowUpload(true);
try {
const ebookList = await booksApi.getEBooks();
setEbooks(ebookList.filter((e) => e.format === "epub"));
} catch {
// Ebook list fetch failed silently
}
};
const handleUpload = async () => {
if (!selectedEbookId || !id) return;
setUploading(true);
try {
const created = await groupsApi.createGroupBook(id, { ebook_id: selectedEbookId });
setShowUpload(false);
setSelectedEbookId(null);
await loadData();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to upload book to group"));
} finally {
setUploading(false);
}
};
const handleAddMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!memberEmail.trim() || !id) return;
setAdding(true);
try {
// Note: In a real implementation, we'd look up user by email.
// For now this requires user_id. Email lookup would be an enhancement.
await groupsApi.addMember(id, { user_id: Number(memberEmail) });
setShowAddMember(false);
setMemberEmail("");
await loadData();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to add member. Please use a valid user ID."));
} finally {
setAdding(false);
}
};
const handleRemoveMember = async (userId: number) => {
if (!id) return;
try {
await groupsApi.removeMember(id, { user_id: userId });
await loadData();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to remove member"));
}
};
if (loading) {
return (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p>
</div>
);
}
if (!group) {
return (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#e74c3c", padding: 40 }}>Group not found</p>
<button onClick={() => navigate("/groups")} style={{ display: "block", margin: "0 auto" }}> Back to Groups</button>
</div>
);
}
return (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{group.name}</h1>
{group.description && <p style={{ margin: "4px 0 0", fontSize: 14, color: "#666" }}>{group.description}</p>}
</div>
<button onClick={() => navigate("/groups")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}>
{t("common.back")}
</button>
</header>
{error && (
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}>
{error}
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button>
</div>
)}
{/* Members Section */}
<section style={{ background: "#fff", padding: 20, borderRadius: 12, marginBottom: 20, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>{t("groups.members")} ({group.members.length})</h2>
<button
onClick={() => setShowAddMember(!showAddMember)}
style={{ padding: "6px 14px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 13 }}
>
{showAddMember ? t("groups.cancel") : `+ ${t("groups.addMember")}`}
</button>
</div>
{showAddMember && (
<form onSubmit={handleAddMember} style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input
type="text"
value={memberEmail}
onChange={(e) => setMemberEmail(e.target.value)}
placeholder={t("groups.memberIdPlaceholder")}
style={{ flex: 1, padding: "8px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 14, outline: "none" }}
/>
<button
type="submit"
disabled={adding}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, cursor: "pointer", opacity: adding ? 0.6 : 1 }}
>
{t("groups.add")}
</button>
</form>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{group.members.map((m) => (
<div key={m.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #f0f0f0" }}>
<div>
<span style={{ fontSize: 14, fontWeight: 500 }}>{m.user_email}</span>
<span style={{ marginLeft: 8, fontSize: 12, color: m.role === "admin" ? "#e67e22" : "#999", background: m.role === "admin" ? "#fef3e2" : "#f0f0f0", padding: "2px 8px", borderRadius: 4 }}>
{m.role}
</span>
</div>
{m.role !== "admin" && (
<button
onClick={() => handleRemoveMember(m.user)}
style={{ background: "none", border: "none", color: "#e74c3c", cursor: "pointer", fontSize: 13 }}
>
{t("groups.remove")}
</button>
)}
</div>
))}
</div>
</section>
{/* Books Section */}
<section style={{ background: "#fff", padding: 20, borderRadius: 12, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>{t("groups.books")} ({books.length})</h2>
<button
onClick={handleUploadClick}
style={{ padding: "8px 18px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.uploadEpub")}
</button>
</div>
{showUpload && (
<div style={{ background: "#f8f9fa", padding: 16, borderRadius: 8, marginBottom: 16 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: "0 0 12px" }}>{t("groups.selectEbook")}</h3>
{ebooks.length === 0 ? (
<div style={{ textAlign: "center", padding: 20, color: "#888" }}>
<p>{t("groups.noEpubBooks")}</p>
<button onClick={() => navigate("/add")} style={{ marginTop: 8, padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 13 }}>
{t("groups.uploadFirst")}
</button>
</div>
) : (
<div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12, maxHeight: 200, overflowY: "auto" }}>
{ebooks.map((eb) => (
<label
key={eb.id}
style={{
display: "flex", alignItems: "center", gap: 10, padding: "10px 12px",
borderRadius: 8, cursor: "pointer", fontSize: 14,
background: selectedEbookId === eb.id ? "#e8f0fe" : "#fff",
border: selectedEbookId === eb.id ? "2px solid #1a1a2e" : "1px solid #eee",
}}
>
<input
type="radio"
name="ebook"
value={eb.id}
checked={selectedEbookId === eb.id}
onChange={() => setSelectedEbookId(eb.id)}
style={{ accentColor: "#1a1a2e" }}
/>
<div>
<strong>{eb.title}</strong>
{eb.author && <span style={{ color: "#888", marginLeft: 8 }}>by {eb.author}</span>}
<span style={{ marginLeft: 8, fontSize: 12, color: "#aaa" }}>({eb.filename})</span>
</div>
</label>
))}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={handleUpload}
disabled={!selectedEbookId || uploading}
style={{ padding: "10px 20px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: uploading ? "default" : "pointer", opacity: uploading ? 0.6 : 1 }}
>
{uploading ? t("common.loading") : t("groups.confirmUpload")}
</button>
<button
onClick={() => setShowUpload(false)}
style={{ padding: "10px 20px", borderRadius: 8, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}
>
{t("groups.cancel")}
</button>
</div>
</div>
)}
</div>
)}
{books.length === 0 ? (
<p style={{ textAlign: "center", color: "#888", padding: 30 }}>{t("groups.noBooks")}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{books.map((book) => (
<div
key={book.id}
onClick={() => navigate(`/groups/${groupId}/books/${book.id}`)}
style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: 16, borderRadius: 10, cursor: "pointer", transition: "background 0.2s",
background: book.status === "active" ? "#f0faf0" : "#fafafa",
border: book.status === "active" ? "2px solid #27ae60" : "1px solid #eee",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = book.status === "active" ? "#e8f5e9" : "#f5f5f5")}
onMouseLeave={(e) => (e.currentTarget.style.background = book.status === "active" ? "#f0faf0" : "#fafafa")}
>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: "#1a1a2e" }}>
{book.title}
{book.status === "active" && (
<span style={{ marginLeft: 8, fontSize: 11, color: "#27ae60", background: "#e8f5e9", padding: "2px 8px", borderRadius: 4 }}>Active</span>
)}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>
{book.section_count} sections · {book.ebook.page_count} chapters · uploaded by {book.uploaded_by_email}
</p>
</div>
<span style={{ fontSize: 20, color: "#ccc" }}></span>
</div>
))}
</div>
)}
</section>
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { groupsApi } from "../api/groups";
import { getApiErrorMessage } from "../api/errors";
import type { ReadingGroup } from "../types/group";
export function GroupsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [groups, setGroups] = useState<ReadingGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const data = await groupsApi.listGroups();
setGroups(data);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to load groups"));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadGroups();
}, [loadGroups]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
await groupsApi.createGroup({ name: newName.trim(), description: newDesc.trim() });
setShowCreate(false);
setNewName("");
setNewDesc("");
await loadGroups();
} catch (err) {
setError(getApiErrorMessage(err, "Failed to create group"));
} finally {
setCreating(false);
}
};
return (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{t("groups.title")}</h1>
<button
type="button"
onClick={() => navigate("/")}
style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}
>
{t("common.back")}
</button>
</header>
{error && (
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}>
{error}
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button>
</div>
)}
<div style={{ marginBottom: 20 }}>
<button
type="button"
onClick={() => setShowCreate(!showCreate)}
style={{
padding: "12px 24px", borderRadius: 8, border: "none",
background: "#1a1a2e", color: "#fff", fontSize: 15, fontWeight: 600, cursor: "pointer",
}}
>
{showCreate ? t("groups.cancel") : t("groups.createGroup")}
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} style={{
background: "#fff", padding: 20, borderRadius: 12, marginBottom: 24,
boxShadow: "0 2px 8px rgba(0,0,0,0.06)", display: "flex", flexDirection: "column", gap: 14,
}}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.groupName")}</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("groups.groupNamePlaceholder")}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }}
required
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.description")} ({t("common.optional")})</label>
<textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder={t("groups.descriptionPlaceholder")}
rows={3}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 15, outline: "none", resize: "vertical" }}
/>
</div>
<button
type="submit"
disabled={creating || !newName.trim()}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: "#1a1a2e", color: "#fff", fontSize: 15, fontWeight: 600,
cursor: creating ? "default" : "pointer", opacity: creating ? 0.6 : 1, alignSelf: "flex-start",
}}
>
{creating ? t("common.loading") : t("groups.create")}
</button>
</form>
)}
{loading ? (
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p>
) : groups.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16, marginBottom: 8 }}>{t("groups.noGroups")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noGroupsHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{groups.map((group) => (
<div
key={group.id}
onClick={() => navigate(`/groups/${group.id}`)}
style={{
background: "#fff", padding: 20, borderRadius: 12, cursor: "pointer",
boxShadow: "0 2px 8px rgba(0,0,0,0.06)", transition: "box-shadow 0.2s",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 4px 16px rgba(0,0,0,0.1)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)")}
>
<div>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#1a1a2e" }}>{group.name}</h3>
{group.description && (
<p style={{ margin: "6px 0 0", fontSize: 14, color: "#666" }}>{group.description}</p>
)}
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#999" }}>
{group.member_count} {t("groups.members")} · {t("groups.admin")}: {group.admin_email}
</p>
</div>
<span style={{ fontSize: 20, color: "#ccc" }}></span>
</div>
))}
</div>
)}
</div>
);
}
+4
View File
@@ -19,6 +19,7 @@ import { useVoiceSearch } from "../hooks/useVoiceSearch";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
import { SearchSuggestions } from "../components/search/SearchSuggestions"; import { SearchSuggestions } from "../components/search/SearchSuggestions";
import { BookContextMenu } from "../components/BookContextMenu"; import { BookContextMenu } from "../components/BookContextMenu";
import { PaceNotification } from "../components/notifications/PaceNotification";
interface FilterState { interface FilterState {
genre: string; genre: string;
@@ -256,6 +257,9 @@ export function LibraryPage() {
</div> </div>
</header> </header>
{/* Pace Notifications */}
<PaceNotification />
{/* Search Bar */} {/* Search Bar */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
+61 -3
View File
@@ -2,7 +2,9 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite"; import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books"; import { booksApi } from "../api/books";
import { groupsApi } from "../api/groups";
import type { ReadingSettings } from "../types/book"; import type { ReadingSettings } from "../types/book";
import type { NotificationPreferences } from "../types/notifications";
import type { SupportedLanguage } from "../locales"; import type { SupportedLanguage } from "../locales";
import { SimpleFormPageLayout } from "../components/layout/SimpleFormPageLayout"; import { SimpleFormPageLayout } from "../components/layout/SimpleFormPageLayout";
@@ -17,6 +19,7 @@ export function SettingsPage() {
const { t, language, setLanguage } = useTranslation(); const { t, language, setLanguage } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [settings, setSettings] = useState<ReadingSettings | null>(null); const [settings, setSettings] = useState<ReadingSettings | null>(null);
const [notifPrefs, setNotifPrefs] = useState<NotificationPreferences | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -24,7 +27,14 @@ export function SettingsPage() {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { const data = await booksApi.getSettings(); setSettings(data); } try {
const [data, notifData] = await Promise.all([
booksApi.getSettings(),
groupsApi.getPreferences(),
]);
setSettings(data);
setNotifPrefs(notifData);
}
catch (err) { setError(err instanceof Error ? err.message : t("settings.loadFailed")); } catch (err) { setError(err instanceof Error ? err.message : t("settings.loadFailed")); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -32,9 +42,15 @@ export function SettingsPage() {
}, [t]); }, [t]);
const handleSave = async () => { const handleSave = async () => {
if (!settings) return; if (!settings || !notifPrefs) return;
setSaving(true); setError(null); setSuccess(false); setSaving(true); setError(null); setSuccess(false);
try { await booksApi.updateSettings(settings); setSuccess(true); setTimeout(() => setSuccess(false), 2000); } try {
await Promise.all([
booksApi.updateSettings(settings),
groupsApi.updatePreferences(notifPrefs),
]);
setSuccess(true); setTimeout(() => setSuccess(false), 2000);
}
catch (err) { setError(err instanceof Error ? err.message : t("settings.saveFailed")); } catch (err) { setError(err instanceof Error ? err.message : t("settings.saveFailed")); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
@@ -85,6 +101,48 @@ export function SettingsPage() {
</div> </div>
</>} </>}
{notifPrefs && <>
<div style={{ borderTop: "1px solid #e5e7eb", paddingTop: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 16 }}>
Notification Settings
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 14, color: "#374151", cursor: "pointer" }}>
<input
type="checkbox"
checked={notifPrefs.pace_notifications_enabled}
onChange={(e) => setNotifPrefs({ ...notifPrefs, pace_notifications_enabled: e.target.checked })}
style={{ width: 18, height: 18, cursor: "pointer" }}
/>
Enable pace notifications
</label>
<label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 14, color: "#374151", cursor: "pointer" }}>
<input
type="checkbox"
checked={notifPrefs.reminder_enabled}
onChange={(e) => setNotifPrefs({ ...notifPrefs, reminder_enabled: e.target.checked })}
style={{ width: 18, height: 18, cursor: "pointer" }}
/>
Enable pre-meeting reminders (24h before)
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Notification frequency</label>
<select
value={notifPrefs.frequency}
onChange={(e) => setNotifPrefs({ ...notifPrefs, frequency: e.target.value as NotificationPreferences["frequency"] })}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, background: "#fff", outline: "none" }}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
</div>
</div>
</>}
<button onClick={handleSave} disabled={saving} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: saving ? 0.6 : 1, marginTop: 8 }}>{saving ? t("common.saving") : t("settings.saveSettings")}</button> <button onClick={handleSave} disabled={saving} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: saving ? 0.6 : 1, marginTop: 8 }}>{saving ? t("common.saving") : t("settings.saveSettings")}</button>
</div> </div>
</SimpleFormPageLayout> </SimpleFormPageLayout>
-109
View File
@@ -1,109 +0,0 @@
/** Types for reading groups, group books, sections, and schedules */
export interface ReadingGroup {
id: number;
name: string;
description: string;
admin: number;
admin_email: string;
member_count: number;
created_at: string;
updated_at: string;
}
export interface ReadingGroupDetail {
id: number;
name: string;
description: string;
admin: number;
admin_email: string;
members: GroupMembership[];
created_at: string;
updated_at: string;
}
export interface GroupMembership {
id: number;
user: number;
user_email: string;
user_username: string;
role: "admin" | "member";
joined_at: string;
}
export interface GroupBook {
id: number;
title: string;
status: "active" | "replaced";
ebook: {
id: number;
title: string;
author: string;
filename: string;
format: string;
page_count: number;
file_size: number;
cover_image: string | null;
created_at: string;
};
uploaded_by: number;
uploaded_by_email: string;
section_count: number;
created_at: string;
updated_at: string;
}
export interface Section {
id: number;
title: string;
order: number;
start_chapter_index: number;
end_chapter_index: number;
estimated_reading_minutes: number;
created_at: string;
}
export interface GroupBookDetail extends GroupBook {
sections: Section[];
schedules: ReadingSchedule[];
}
export interface ReadingSchedule {
id: number;
meeting_number: number;
week_date: string;
section_ids: number[];
section_details: Section[];
created_at: string;
}
export interface MemberProgress {
id: number;
user: number;
user_email: string;
user_username: string;
current_section: number | null;
current_section_title: string | null;
completed_sections: number[];
updated_at: string;
}
export interface CreateGroupPayload {
name: string;
description?: string;
}
export interface AddMemberPayload {
user_id: number;
}
export interface CreateGroupBookPayload {
ebook_id: number;
title?: string;
}
export interface AdjustSectionsPayload {
operation: "merge" | "split";
section_ids: number[];
split_at?: number;
}
+65
View File
@@ -0,0 +1,65 @@
export interface PaceStatus {
meeting_id: number;
meeting_title: string;
scheduled_at: string;
target_section: number;
target_section_label: string;
current_section: number;
ahead_behind_delta: number;
status: "ahead" | "behind" | "on_track" | "completed";
days_until_meeting: number;
sections_to_catch_up: number;
ebook_id: number;
ebook_title: string;
}
export interface NotificationPreferences {
pace_notifications_enabled: boolean;
reminder_enabled: boolean;
frequency: "daily" | "weekly";
}
export interface MeetingReminder {
meeting_id: number;
meeting_title: string;
scheduled_at: string;
target_section: number;
target_section_label: string;
group_name: string;
hours_until: number;
ebook_id: number;
ebook_title: string;
}
export interface ReadingGroup {
id: number;
name: string;
description: string;
book: number | null;
ebook: number | null;
created_by: number;
created_at: string;
member_count: number;
meeting_count: number;
}
export interface GroupMeeting {
id: number;
group: number;
group_name: string;
title: string;
scheduled_at: string;
target_section: number;
target_section_label: string;
created_at: string;
}
export interface GroupMembership {
id: number;
user: number;
user_email: string;
group: number;
group_name: string;
role: "admin" | "member";
joined_at: string;
}
-44
View File
@@ -216,47 +216,3 @@ export interface ApiError {
detail?: string; detail?: string;
[key: string]: unknown; [key: string]: unknown;
} }
// ---- Reading Groups ----
export interface ReadingGroup {
id: number;
name: string;
description: string;
admin: number;
admin_email: string;
member_count: number;
created_at: string;
updated_at: string;
}
export interface GroupMembership {
id: number;
user: number;
user_email: string;
user_username: string;
role: "admin" | "member";
joined_at: string;
}
export interface GroupBookSummary {
id: number;
title: string;
status: "active" | "replaced";
section_count: number;
created_at: string;
}
export interface SectionSummary {
id: number;
title: string;
order: number;
estimated_reading_minutes: number;
}
export interface ReadingScheduleEntry {
id: number;
meeting_number: number;
week_date: string;
section_ids: number[];
}
-10
View File
@@ -114,14 +114,4 @@ export const API_ENDPOINTS = {
bookmarkDetail: (id: string) => `/api/annotations/bookmarks/${id}/`, bookmarkDetail: (id: string) => `/api/annotations/bookmarks/${id}/`,
noteDetail: (id: string) => `/api/annotations/notes/${id}/`, noteDetail: (id: string) => `/api/annotations/notes/${id}/`,
}, },
groups: {
list: "/api/groups/",
detail: (id: number) => `/api/groups/${id}/`,
books: (groupId: number) => `/api/groups/${groupId}/books/`,
bookDetail: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/`,
detectSections: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/detect-sections/`,
adjustSections: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/adjust-sections/`,
schedule: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/schedule/`,
progress: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/progress/`,
},
} as const; } as const;