From e80c2673eff86319a5169adf351df36a6b73563b Mon Sep 17 00:00:00 2001 From: Marko Date: Sat, 20 Jun 2026 19:33:41 +0000 Subject: [PATCH] feat: implement reading pace notifications (US #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/apps/groups/__init__.py | 0 backend/apps/groups/admin.py | 38 +++ backend/apps/groups/apps.py | 8 + .../apps/groups/migrations/0001_initial.py | 109 +++++++++ backend/apps/groups/migrations/__init__.py | 0 backend/apps/groups/models.py | 190 +++++++++++++++ backend/apps/groups/serializers.py | 137 +++++++++++ backend/apps/groups/services/__init__.py | 0 backend/apps/groups/services/pace.py | 219 ++++++++++++++++++ backend/apps/groups/urls.py | 23 ++ backend/apps/groups/views.py | 207 +++++++++++++++++ backend/config/django.py | 1 + backend/config/urls.py | 1 + frontend/src/api/groups.ts | 57 +++++ .../notifications/PaceNotification.tsx | 119 ++++++++++ .../notifications/ReadingPaceBanner.tsx | 71 ++++++ .../src/components/reader/EpubReadingView.tsx | 5 + .../src/components/reader/PdfReadingView.tsx | 5 + frontend/src/pages/Library.tsx | 4 + frontend/src/pages/Settings.tsx | 64 ++++- frontend/src/types/notifications.ts | 65 ++++++ 21 files changed, 1320 insertions(+), 3 deletions(-) create mode 100644 backend/apps/groups/__init__.py create mode 100644 backend/apps/groups/admin.py create mode 100644 backend/apps/groups/apps.py create mode 100644 backend/apps/groups/migrations/0001_initial.py create mode 100644 backend/apps/groups/migrations/__init__.py create mode 100644 backend/apps/groups/models.py create mode 100644 backend/apps/groups/serializers.py create mode 100644 backend/apps/groups/services/__init__.py create mode 100644 backend/apps/groups/services/pace.py create mode 100644 backend/apps/groups/urls.py create mode 100644 backend/apps/groups/views.py create mode 100644 frontend/src/api/groups.ts create mode 100644 frontend/src/components/notifications/PaceNotification.tsx create mode 100644 frontend/src/components/notifications/ReadingPaceBanner.tsx create mode 100644 frontend/src/types/notifications.ts diff --git a/backend/apps/groups/__init__.py b/backend/apps/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/admin.py b/backend/apps/groups/admin.py new file mode 100644 index 0000000..01ea07f --- /dev/null +++ b/backend/apps/groups/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from apps.groups.models import ( + GroupMeeting, + GroupMembership, + NotificationPreference, + PaceNotification, + ReadingGroup, +) + + +@admin.register(ReadingGroup) +class ReadingGroupAdmin(admin.ModelAdmin): + list_display = ["name", "created_by", "created_at"] + search_fields = ["name"] + + +@admin.register(GroupMembership) +class GroupMembershipAdmin(admin.ModelAdmin): + list_display = ["user", "group", "role", "joined_at"] + list_filter = ["role"] + + +@admin.register(GroupMeeting) +class GroupMeetingAdmin(admin.ModelAdmin): + 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"] \ No newline at end of file diff --git a/backend/apps/groups/apps.py b/backend/apps/groups/apps.py new file mode 100644 index 0000000..68ce887 --- /dev/null +++ b/backend/apps/groups/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class GroupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.groups" + verbose_name = "Reading Groups" + label = "groups" diff --git a/backend/apps/groups/migrations/0001_initial.py b/backend/apps/groups/migrations/0001_initial.py new file mode 100644 index 0000000..c42221e --- /dev/null +++ b/backend/apps/groups/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 5.1.7 on 2026-06-20 19:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('books', '0003_readingprogress_epub_location'), + ('users', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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( + name='ReadingGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=256)), + ('description', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.book')), + ('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={ + 'verbose_name': 'Reading Group', + 'verbose_name_plural': 'Reading Groups', + 'db_table': 'groups_reading_group', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='GroupMeeting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, default='', max_length=512)), + ('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)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='groups.readinggroup')), + ], + options={ + 'verbose_name': 'Group Meeting', + 'verbose_name_plural': 'Group Meetings', + 'db_table': 'groups_meeting', + 'ordering': ['scheduled_at'], + }, + ), + migrations.CreateModel( + 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=[ + ('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)), + ('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')), + ], + options={ + 'verbose_name': 'Group Membership', + 'verbose_name_plural': 'Group Memberships', + 'db_table': 'groups_membership', + 'indexes': [models.Index(fields=['user', 'group'], name='groups_memb_user_id_9cda7e_idx')], + 'unique_together': {('user', 'group')}, + }, + ), + ] diff --git a/backend/apps/groups/migrations/__init__.py b/backend/apps/groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/models.py b/backend/apps/groups/models.py new file mode 100644 index 0000000..00f49ac --- /dev/null +++ b/backend/apps/groups/models.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import models + + +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): + """A group of users reading a book together with scheduled meetings.""" + + name = models.CharField(max_length=256, db_index=True) + description = models.TextField(blank=True, default="") + book = models.ForeignKey( + "books.Book", + on_delete=models.SET_NULL, + null=True, + blank=True, + 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) + + class Meta: + db_table = "groups_reading_group" + verbose_name = "Reading Group" + verbose_name_plural = "Reading Groups" + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class GroupMembership(models.Model): + """User membership in a reading group.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + 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) + + class Meta: + db_table = "groups_membership" + verbose_name = "Group Membership" + verbose_name_plural = "Group Memberships" + unique_together = [("user", "group")] + indexes = [models.Index(fields=["user", "group"])] + + def __str__(self) -> str: + return f"{self.user} in {self.group}" + + +class GroupMeeting(models.Model): + """A scheduled meeting for a reading group with a target section.""" + + group = models.ForeignKey( + ReadingGroup, + on_delete=models.CASCADE, + related_name="meetings", + ) + title = models.CharField(max_length=512, blank=True, default="") + 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( + max_length=512, + blank=True, + default="", + help_text="Human-readable label for the target section (e.g. 'Section 3')", + ) + created_at = models.DateTimeField(auto_now_add=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) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_notification_preference" + verbose_name = "Notification Preference" + verbose_name_plural = "Notification Preferences" + + def __str__(self) -> str: + return f"{self.user} — pace={'on' if self.pace_notifications_enabled else 'off'}, freq={self.frequency}" + + +class PaceNotification(models.Model): + """Tracks reading pace notifications sent to users to avoid duplicates.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="pace_notifications", + ) + meeting = models.ForeignKey( + GroupMeeting, + on_delete=models.CASCADE, + related_name="pace_notifications", + ) + ebook = models.ForeignKey( + "books.EBook", + on_delete=models.CASCADE, + related_name="pace_notifications", + null=True, + blank=True, + ) + status = models.CharField( + max_length=16, + choices=NotificationStatus.choices, + default=NotificationStatus.PENDING, + ) + 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: + db_table = "groups_pace_notification" + verbose_name = "Pace Notification" + verbose_name_plural = "Pace Notifications" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "meeting", "ebook"]), + ] + + def __str__(self) -> str: + return f"{self.user} — meeting {self.meeting_id}: {self.get_status_display()}" diff --git a/backend/apps/groups/serializers.py b/backend/apps/groups/serializers.py new file mode 100644 index 0000000..4f4b4ae --- /dev/null +++ b/backend/apps/groups/serializers.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from rest_framework import serializers + +from apps.groups.models import ( + FrequencyChoice, + GroupMeeting, + GroupMembership, + NotificationPreference, + PaceNotification, + ReadingGroup, +) +from apps.groups.services.pace import PaceStatus + + +class NotificationPreferenceSerializer(serializers.ModelSerializer): + class Meta: + 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: + model = GroupMeeting + fields = [ + "id", + "group", + "group_name", + "title", + "scheduled_at", + "target_section", + "target_section_label", + "created_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 ReadingGroupSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() + meeting_count = serializers.SerializerMethodField() + + class Meta: + model = ReadingGroup + fields = [ + "id", + "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: + return obj.memberships.count() + + def get_meeting_count(self, obj: ReadingGroup) -> int: + return obj.meetings.count() + + +class GroupMembershipSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + group_name = serializers.CharField(source="group.name", read_only=True) + + class Meta: + model = GroupMembership + fields = ["id", "user", "user_email", "group", "group_name", "role", "joined_at"] + read_only_fields = ["id", "joined_at"] + + +class PaceNotificationSerializer(serializers.ModelSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = PaceNotification + fields = [ + "id", + "user", + "meeting", + "ebook", + "status", + "status_display", + "current_section", + "target_section", + "ahead_behind_delta", + "created_at", + ] + read_only_fields = ["id", "created_at"] + + +class PaceStatusSerializer(serializers.Serializer): + meeting_id = serializers.IntegerField() + meeting_title = serializers.CharField() + scheduled_at = serializers.CharField() + target_section = serializers.IntegerField() + target_section_label = serializers.CharField() + current_section = serializers.IntegerField() + ahead_behind_delta = serializers.IntegerField() + status = serializers.CharField() + days_until_meeting = serializers.IntegerField() + sections_to_catch_up = serializers.IntegerField() + ebook_id = serializers.IntegerField() + ebook_title = serializers.CharField() + + +class ReminderSerializer(serializers.Serializer): + meeting_id = serializers.IntegerField() + meeting_title = serializers.CharField() + scheduled_at = serializers.CharField() + target_section = serializers.IntegerField() + target_section_label = serializers.CharField() + group_name = serializers.CharField() + hours_until = serializers.IntegerField() + ebook_id = serializers.IntegerField() + ebook_title = serializers.CharField() \ No newline at end of file diff --git a/backend/apps/groups/services/__init__.py b/backend/apps/groups/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/services/pace.py b/backend/apps/groups/services/pace.py new file mode 100644 index 0000000..7840699 --- /dev/null +++ b/backend/apps/groups/services/pace.py @@ -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") + ) diff --git a/backend/apps/groups/urls.py b/backend/apps/groups/urls.py new file mode 100644 index 0000000..de76e93 --- /dev/null +++ b/backend/apps/groups/urls.py @@ -0,0 +1,23 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.groups.views import ( + GroupMeetingViewSet, + ReadingGroupViewSet, + dismiss_pace_notification_view, + notification_preferences_view, + pace_status_view, + reminder_view, +) + +router = DefaultRouter() +router.register(r"groups", ReadingGroupViewSet, basename="reading-group") +router.register(r"meetings", GroupMeetingViewSet, basename="group-meeting") + +urlpatterns = [ + path("", include(router.urls)), + path("notifications/preferences/", notification_preferences_view, name="notification-preferences"), + path("notifications/pace/", pace_status_view, name="pace-status"), + path("notifications/dismiss/", dismiss_pace_notification_view, name="dismiss-pace"), + path("notifications/reminders/", reminder_view, name="meeting-reminders"), +] \ No newline at end of file diff --git a/backend/apps/groups/views.py b/backend/apps/groups/views.py new file mode 100644 index 0000000..0447692 --- /dev/null +++ b/backend/apps/groups/views.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from django.db.models import QuerySet +from rest_framework import permissions, status, viewsets +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.response import Response + +from apps.books.models import EBook +from apps.groups.models import ( + GroupMeeting, + GroupMembership, + NotificationPreference, + PaceNotification, + NotificationStatus, + ReadingGroup, +) +from apps.groups.serializers import ( + GroupMeetingSerializer, + GroupMembershipSerializer, + NotificationPreferenceSerializer, + PaceNotificationSerializer, + PaceStatusSerializer, + ReadingGroupSerializer, + ReminderSerializer, +) +from apps.groups.services.pace import ( + calculate_pace, + get_reminder_meetings, + should_send_notification, +) + + +class IsGroupMember(permissions.BasePermission): + """Only allow group members to access group resources.""" + + def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool: + return obj.memberships.filter(user=request.user).exists() + + +class ReadingGroupViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsGroupMember] + serializer_class = ReadingGroupSerializer + + def get_queryset(self) -> QuerySet[ReadingGroup]: + return ( + ReadingGroup.objects.filter(memberships__user=self.request.user) + .prefetch_related("memberships", "meetings") + .distinct() + ) + + def perform_create(self, serializer) -> None: + group = serializer.save(created_by=self.request.user) + GroupMembership.objects.create( + user=self.request.user, + group=group, + role="admin", + ) + + @action(detail=False, methods=["get"], url_path="all") + def all_groups(self, request: Request) -> Response: + """Return all groups (for discovery).""" + qs = ReadingGroup.objects.all().prefetch_related("memberships", "meetings") + page = self.paginate_queryset(qs) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data) + + +class GroupMeetingViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = GroupMeetingSerializer + + def get_queryset(self) -> QuerySet[GroupMeeting]: + user_group_ids = ( + 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) \ No newline at end of file diff --git a/backend/config/django.py b/backend/config/django.py index 4019b7c..bd5a19c 100644 --- a/backend/config/django.py +++ b/backend/config/django.py @@ -41,6 +41,7 @@ INSTALLED_APPS = [ "apps.books", "apps.annotations", "apps.reader", + "apps.groups", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index 07d350e..525b8bc 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("api/books/", include("apps.books.urls")), path("api/annotations/", include("apps.annotations.urls")), path("api/reader/", include("apps.reader.urls")), + path("api/groups/", include("apps.groups.urls")), ] if settings.DEBUG: diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts new file mode 100644 index 0000000..4c6072e --- /dev/null +++ b/frontend/src/api/groups.ts @@ -0,0 +1,57 @@ +import api from "./client"; +import type { + GroupMeeting, + MeetingReminder, + NotificationPreferences, + PaceStatus, + ReadingGroup, +} from "../types/notifications"; + +export const groupsApi = { + async getPaceStatus(ebookId?: number): Promise { + const params: Record = {}; + if (ebookId) params.ebook_id = String(ebookId); + const { data } = await api.get("/groups/notifications/pace/", { params }); + return data; + }, + + async getPreferences(): Promise { + const { data } = await api.get("/groups/notifications/preferences/"); + return data; + }, + + async updatePreferences( + prefs: Partial, + ): Promise { + const { data } = await api.patch( + "/groups/notifications/preferences/", + prefs, + ); + return data; + }, + + async dismissPaceNotification(meetingId: number, ebookId: number): Promise { + await api.post("/groups/notifications/dismiss/", { + meeting_id: meetingId, + ebook_id: ebookId, + }); + }, + + async getReminders(): Promise { + const { data } = await api.get("/groups/notifications/reminders/"); + return data; + }, + + async getGroups(): Promise { + const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>( + "/groups/groups/", + ); + if (Array.isArray(data)) return data; + return data.results ?? []; + }, + + async getUpcomingMeetings(): Promise { + const { data } = await api.get("/groups/meetings/upcoming/"); + return data; + }, +}; \ No newline at end of file diff --git a/frontend/src/components/notifications/PaceNotification.tsx b/frontend/src/components/notifications/PaceNotification.tsx new file mode 100644 index 0000000..0126dd4 --- /dev/null +++ b/frontend/src/components/notifications/PaceNotification.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [dismissed, setDismissed] = useState>(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 ( +
+ {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 ( +
+
+
+ {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!"} +
+
+ {item.meeting_title || `Target: ${item.target_section_label || `Section ${item.target_section}`}`} + {" — "} + {item.ebook_title} +
+
+ {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"} +
+
+ +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/notifications/ReadingPaceBanner.tsx b/frontend/src/components/notifications/ReadingPaceBanner.tsx new file mode 100644 index 0000000..08d1d1b --- /dev/null +++ b/frontend/src/components/notifications/ReadingPaceBanner.tsx @@ -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([]); + + 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 ( +
+ {relevantItems.map((item) => { + const isAlmostThere = item.sections_to_catch_up === 1; + const isAtTarget = item.sections_to_catch_up === 0; + + return ( + + {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)`} + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/reader/EpubReadingView.tsx b/frontend/src/components/reader/EpubReadingView.tsx index e864a5b..5bf9feb 100644 --- a/frontend/src/components/reader/EpubReadingView.tsx +++ b/frontend/src/components/reader/EpubReadingView.tsx @@ -22,6 +22,7 @@ import { SelectionPopover } from "./SelectionPopover"; import { BookMarkersPanel } from "./BookMarkersPanel"; import { BookmarkReaderRail } from "./BookmarkReaderRail"; import { ResumeReadingButton } from "./ResumeReadingButton"; +import { ReadingPaceBanner } from "../notifications/ReadingPaceBanner"; import type { MarkerEntry } from "@/types"; const ReaderToolbar = lazy(() => import("./ReaderToolbar")); @@ -130,6 +131,10 @@ export function EpubReadingView({ book, bookId, initialEpubLocation }: EpubReadi return ( + 0 ? Math.max(1, Math.ceil((progress / 100) * tocItems.length)) : 0} + /> import("./ReaderToolbar")); @@ -170,6 +171,10 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr return ( + + {/* Pace Notifications */} + + {/* Search Bar */}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a8782cc..0c07e58 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18n-lite"; import { booksApi } from "../api/books"; +import { groupsApi } from "../api/groups"; import type { ReadingSettings } from "../types/book"; +import type { NotificationPreferences } from "../types/notifications"; import type { SupportedLanguage } from "../locales"; import { SimpleFormPageLayout } from "../components/layout/SimpleFormPageLayout"; @@ -17,6 +19,7 @@ export function SettingsPage() { const { t, language, setLanguage } = useTranslation(); const navigate = useNavigate(); const [settings, setSettings] = useState(null); + const [notifPrefs, setNotifPrefs] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -24,7 +27,14 @@ export function SettingsPage() { useEffect(() => { 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")); } finally { setLoading(false); } }; @@ -32,9 +42,15 @@ export function SettingsPage() { }, [t]); const handleSave = async () => { - if (!settings) return; + if (!settings || !notifPrefs) return; 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")); } finally { setSaving(false); } }; @@ -85,6 +101,48 @@ export function SettingsPage() {
} + {notifPrefs && <> +
+

+ Notification Settings +

+ +
+ + + + +
+ + +
+
+
+ } +
diff --git a/frontend/src/types/notifications.ts b/frontend/src/types/notifications.ts new file mode 100644 index 0000000..abede40 --- /dev/null +++ b/frontend/src/types/notifications.ts @@ -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; +} \ No newline at end of file -- 2.54.0