Archived
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
This commit is contained in:
@@ -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")
|
||||
)
|
||||
Reference in New Issue
Block a user