From 8a86cf83884ef64e39bc2e319e1e8d1113f80bce Mon Sep 17 00:00:00 2001 From: Marko Date: Sat, 20 Jun 2026 19:31:21 +0000 Subject: [PATCH] feat: implement per-member reading progress tracking for shared books (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (apps/groups): - ReadingGroup model: named groups linked to EBooks with creator tracking - GroupMembership model: user-group association with member/admin roles - MemberProgress model: per-member progress with section tracking, percentage, time_spent, privacy toggles, and device-position preservation - ReadingGroupViewSet: CRUD, join/leave, members list, progress endpoints - GET /api/groups/{id}/members/progress/ — all members' progress (public: section label only; own: full detail; private: stubbed) - PATCH /api/groups/{id}/progress/ — update own progress with auto- percentage calculation from section boundaries - GET /api/groups/{id}/progress/summary/ — admin dashboard with averages, started/finished counts, and per-member details - Permissions: IsGroupMember, IsGroupAdmin Frontend: - Groups list page (/groups) with create modal and book selector - Group detail page (/groups/:id) with progress and admin summary tabs - Progress bar visualization per member with privacy-aware display - Admin stat cards (total members, started, finished, avg progress, avg time) - Navigation link from Library header Shared: - ReadingGroupSummary and MemberProgressPublic types - API endpoint constants for groups routes Closes #30 --- backend/apps/groups/__init__.py | 0 backend/apps/groups/admin.py | 23 ++ backend/apps/groups/apps.py | 7 + .../apps/groups/migrations/0001_initial.py | 75 ++++ backend/apps/groups/migrations/__init__.py | 0 backend/apps/groups/models.py | 138 +++++++ backend/apps/groups/permissions.py | 28 ++ backend/apps/groups/serializers.py | 183 +++++++++ backend/apps/groups/urls.py | 11 + backend/apps/groups/views.py | 203 ++++++++++ backend/config/django.py | 1 + backend/config/urls.py | 1 + frontend/src/App.tsx | 4 + frontend/src/api/groups.ts | 81 ++++ frontend/src/pages/GroupDetail.tsx | 381 ++++++++++++++++++ frontend/src/pages/Library.tsx | 2 + frontend/src/pages/ReadingGroups.tsx | 288 +++++++++++++ frontend/src/types/groups.ts | 104 +++++ packages/shared/src/types.ts | 32 ++ packages/shared/src/utils.ts | 9 + 20 files changed, 1571 insertions(+) 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/permissions.py create mode 100644 backend/apps/groups/serializers.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/pages/GroupDetail.tsx create mode 100644 frontend/src/pages/ReadingGroups.tsx create mode 100644 frontend/src/types/groups.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..c26ccde --- /dev/null +++ b/backend/apps/groups/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup + + +@admin.register(ReadingGroup) +class ReadingGroupAdmin(admin.ModelAdmin): + list_display = ["name", "ebook", "created_by", "created_at"] + search_fields = ["name", "ebook__title", "created_by__email"] + + +@admin.register(GroupMembership) +class GroupMembershipAdmin(admin.ModelAdmin): + list_display = ["group", "user", "role", "joined_at"] + list_filter = ["role"] + search_fields = ["user__email", "group__name"] + + +@admin.register(MemberProgress) +class MemberProgressAdmin(admin.ModelAdmin): + list_display = ["user", "group", "current_section", "percentage", "time_spent_seconds", "is_public", "updated_at"] + list_filter = ["is_public"] + search_fields = ["user__email", "group__name"] \ 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..604c8f2 --- /dev/null +++ b/backend/apps/groups/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GroupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.groups" + verbose_name = "Reading Groups" \ No newline at end of file diff --git a/backend/apps/groups/migrations/0001_initial.py b/backend/apps/groups/migrations/0001_initial.py new file mode 100644 index 0000000..0f1dac0 --- /dev/null +++ b/backend/apps/groups/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 5.1.7 on 2026-06-20 19:21 + +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'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + 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)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups', to=settings.AUTH_USER_MODEL)), + ('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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='GroupMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], 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', + 'ordering': ['joined_at'], + 'unique_together': {('group', 'user')}, + }, + ), + migrations.CreateModel( + name='MemberProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('current_section', models.PositiveIntegerField(default=0)), + ('percentage', models.FloatField(default=0.0)), + ('time_spent_seconds', models.PositiveIntegerField(default=0)), + ('last_position', models.JSONField(blank=True, default=dict)), + ('is_public', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='groups.groupmembership')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.readinggroup')), + ], + options={ + 'verbose_name': 'Member Progress', + 'verbose_name_plural': 'Member Progress', + 'db_table': 'groups_member_progress', + 'indexes': [models.Index(fields=['group', '-percentage'], name='groups_memb_group_i_42e333_idx'), models.Index(fields=['group', 'user'], name='groups_memb_group_i_f50793_idx')], + 'unique_together': {('group', 'user')}, + }, + ), + ] 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..e678aa7 --- /dev/null +++ b/backend/apps/groups/models.py @@ -0,0 +1,138 @@ +from django.conf import settings +from django.db import models + + +class ReadingGroup(models.Model): + """A group of users reading the same book together.""" + + name = models.CharField(max_length=256, db_index=True) + ebook = models.ForeignKey( + "books.EBook", + on_delete=models.CASCADE, + related_name="reading_groups", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="created_groups", + ) + description = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=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 + + @property + def total_sections(self) -> int: + return self.ebook.chapters.count() + + +class GroupMembership(models.Model): + class Role(models.TextChoices): + MEMBER = "member", "Member" + ADMIN = "admin", "Admin" + + group = models.ForeignKey( + ReadingGroup, + on_delete=models.CASCADE, + related_name="memberships", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="group_memberships", + ) + role = models.CharField( + max_length=16, + choices=Role.choices, + default=Role.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 = [("group", "user")] + ordering = ["joined_at"] + + def __str__(self) -> str: + return f"{self.user} in {self.group.name} ({self.role})" + + +class MemberProgress(models.Model): + """Per-member reading progress within a reading group.""" + + membership = models.OneToOneField( + GroupMembership, + on_delete=models.CASCADE, + related_name="progress", + ) + group = models.ForeignKey( + ReadingGroup, + on_delete=models.CASCADE, + related_name="member_progress", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="member_progress", + ) + current_section = models.PositiveIntegerField(default=0) + percentage = models.FloatField(default=0.0) + time_spent_seconds = models.PositiveIntegerField(default=0) + last_position = models.JSONField(blank=True, default=dict) + is_public = models.BooleanField(default=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_member_progress" + verbose_name = "Member Progress" + verbose_name_plural = "Member Progress" + unique_together = [("group", "user")] + indexes = [ + models.Index(fields=["group", "-percentage"]), + models.Index(fields=["group", "user"]), + ] + + def __str__(self) -> str: + return f"{self.user} — {self.group.name} ({self.percentage:.0f}%)" + + def total_sections(self) -> int: + return self.group.total_sections + + def update_progress( + self, + current_section: int, + percentage: float | None = None, + time_spent_delta: int = 0, + last_position: dict | None = None, + ) -> None: + """Update progress with computed percentage if not provided.""" + self.current_section = current_section + if percentage is not None: + self.percentage = min(100.0, max(0.0, percentage)) + elif (ts := self.total_sections()) > 0: + self.percentage = min(100.0, (current_section / ts) * 100.0) + else: + self.percentage = 0.0 + if time_spent_delta > 0: + self.time_spent_seconds += time_spent_delta + if last_position is not None: + self.last_position = last_position + self.save( + update_fields=[ + "current_section", + "percentage", + "time_spent_seconds", + "last_position", + "updated_at", + ] + ) \ No newline at end of file diff --git a/backend/apps/groups/permissions.py b/backend/apps/groups/permissions.py new file mode 100644 index 0000000..5b2afc6 --- /dev/null +++ b/backend/apps/groups/permissions.py @@ -0,0 +1,28 @@ +from rest_framework import permissions +from rest_framework.request import Request + +from apps.groups.models import GroupMembership + + +class IsGroupMember(permissions.BasePermission): + """Allow access only to members of the group.""" + + def has_permission(self, request: Request, view: object) -> bool: + group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk") + if not group_id: + return False + return GroupMembership.objects.filter( + group_id=group_id, user=request.user + ).exists() + + +class IsGroupAdmin(permissions.BasePermission): + """Allow access only to group admins.""" + + def has_permission(self, request: Request, view: object) -> bool: + group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk") + if not group_id: + return False + return GroupMembership.objects.filter( + group_id=group_id, user=request.user, role=GroupMembership.Role.ADMIN + ).exists() \ No newline at end of file diff --git a/backend/apps/groups/serializers.py b/backend/apps/groups/serializers.py new file mode 100644 index 0000000..c064548 --- /dev/null +++ b/backend/apps/groups/serializers.py @@ -0,0 +1,183 @@ +from rest_framework import serializers + +from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup + + +class GroupMembershipSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + user_id = serializers.IntegerField(source="user.id", read_only=True) + role = serializers.CharField(read_only=True) + + class Meta: + model = GroupMembership + fields = ["id", "user_id", "user_email", "role", "joined_at"] + + +class MemberProgressSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + user_id = serializers.IntegerField(source="user.id", read_only=True) + total_sections = serializers.SerializerMethodField() + section_label = serializers.SerializerMethodField() + + class Meta: + model = MemberProgress + fields = [ + "id", + "user_id", + "user_email", + "current_section", + "total_sections", + "section_label", + "percentage", + "time_spent_seconds", + "last_position", + "is_public", + "updated_at", + ] + read_only_fields = ["id", "user_id", "user_email", "updated_at"] + extra_kwargs = { + "percentage": {"required": False, "min_value": 0.0, "max_value": 100.0}, + } + + def get_total_sections(self, obj: MemberProgress) -> int: + return obj.total_sections() + + def get_section_label(self, obj: MemberProgress) -> str: + ts = obj.total_sections() + if ts: + return f"Section {obj.current_section} of {ts}" + return "No sections" + + def validate_percentage(self, value: float) -> float: + if value < 0.0 or value > 100.0: + raise serializers.ValidationError("Percentage must be between 0.0 and 100.0.") + return value + + +class MemberProgressPublicSerializer(serializers.ModelSerializer): + """Limited view for other group members — section label only, no exact position.""" + + user_email = serializers.CharField(source="user.email", read_only=True) + user_id = serializers.IntegerField(source="user.id", read_only=True) + total_sections = serializers.SerializerMethodField() + section_label = serializers.SerializerMethodField() + + class Meta: + model = MemberProgress + fields = [ + "user_id", + "user_email", + "current_section", + "total_sections", + "section_label", + "percentage", + "time_spent_seconds", + "updated_at", + ] + + def get_total_sections(self, obj: MemberProgress) -> int: + return obj.total_sections() + + def get_section_label(self, obj: MemberProgress) -> str: + ts = obj.total_sections() + if ts: + return f"Section {obj.current_section} of {ts}" + return "No sections" + + +class MemberProgressUpdateSerializer(serializers.Serializer): + current_section = serializers.IntegerField(min_value=0) + percentage = serializers.FloatField(required=False, min_value=0.0, max_value=100.0) + time_spent_delta = serializers.IntegerField(default=0, min_value=0) + last_position = serializers.JSONField(required=False, default=dict) + is_public = serializers.BooleanField(required=False) + + +class ReadingGroupListSerializer(serializers.ModelSerializer): + created_by_email = serializers.CharField(source="created_by.email", read_only=True) + ebook_title = serializers.CharField(source="ebook.title", read_only=True) + ebook_author = serializers.CharField(source="ebook.author", read_only=True) + member_count = serializers.SerializerMethodField() + my_progress = serializers.SerializerMethodField() + + class Meta: + model = ReadingGroup + fields = [ + "id", + "name", + "ebook", + "ebook_title", + "ebook_author", + "created_by_email", + "description", + "member_count", + "my_progress", + "created_at", + "updated_at", + ] + + def get_member_count(self, obj: ReadingGroup) -> int: + return obj.memberships.count() + + def get_my_progress(self, obj: ReadingGroup) -> dict | None: + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + try: + mp = MemberProgress.objects.get(group=obj, user=request.user) + except MemberProgress.DoesNotExist: + return None + return { + "current_section": mp.current_section, + "percentage": mp.percentage, + "time_spent_seconds": mp.time_spent_seconds, + "section_label": MemberProgressSerializer().get_section_label(mp), + } + + +class ReadingGroupDetailSerializer(ReadingGroupListSerializer): + members = serializers.SerializerMethodField() + + class Meta(ReadingGroupListSerializer.Meta): + fields = ReadingGroupListSerializer.Meta.fields + ["members"] + + def get_members(self, obj: ReadingGroup) -> list[dict]: + memberships = obj.memberships.select_related("user").prefetch_related("progress") + result: list[dict] = [] + for m in memberships: + entry: dict = { + "id": m.id, + "user_id": m.user.id, + "user_email": m.user.email, + "role": m.role, + "joined_at": m.joined_at, + } + try: + mp = m.progress + except MemberProgress.DoesNotExist: + entry["progress"] = None + else: + if mp.is_public: + entry["progress"] = MemberProgressPublicSerializer(mp).data + else: + entry["progress"] = {"is_public": False, "note": "Private"} + result.append(entry) + return result + + +class ReadingGroupCreateSerializer(serializers.ModelSerializer): + class Meta: + model = ReadingGroup + fields = ["name", "ebook", "description"] + + +class AdminProgressSummarySerializer(serializers.Serializer): + group_id = serializers.IntegerField() + group_name = serializers.CharField() + ebook_title = serializers.CharField() + total_members = serializers.IntegerField() + members_started = serializers.IntegerField() + members_finished = serializers.IntegerField() + average_percentage = serializers.FloatField() + average_time_spent_hours = serializers.FloatField() + member_details = serializers.ListField(child=serializers.JSONField()) \ No newline at end of file diff --git a/backend/apps/groups/urls.py b/backend/apps/groups/urls.py new file mode 100644 index 0000000..9273c7a --- /dev/null +++ b/backend/apps/groups/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.groups.views import ReadingGroupViewSet + +router = DefaultRouter() +router.register(r"", ReadingGroupViewSet, basename="reading-group") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/groups/views.py b/backend/apps/groups/views.py new file mode 100644 index 0000000..9297e4d --- /dev/null +++ b/backend/apps/groups/views.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from django.db import models +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup +from apps.groups.permissions import IsGroupAdmin, IsGroupMember +from apps.groups.serializers import ( + AdminProgressSummarySerializer, + GroupMembershipSerializer, + MemberProgressPublicSerializer, + MemberProgressSerializer, + MemberProgressUpdateSerializer, + ReadingGroupCreateSerializer, + ReadingGroupDetailSerializer, + ReadingGroupListSerializer, +) + +import logging + +logger = logging.getLogger(__name__) + + +class ReadingGroupViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return ( + ReadingGroup.objects.filter(memberships__user=self.request.user) + .select_related("ebook", "created_by") + .prefetch_related("memberships", "memberships__progress") + .distinct() + ) + + def get_serializer_class(self): + if self.action == "create": + return ReadingGroupCreateSerializer + if self.action == "retrieve": + return ReadingGroupDetailSerializer + return ReadingGroupListSerializer + + def perform_create(self, serializer): + group = serializer.save(created_by=self.request.user) + # Creator automatically becomes admin member + GroupMembership.objects.create( + group=group, + user=self.request.user, + role=GroupMembership.Role.ADMIN, + ) + + # ── Membership ─────────────────────────────────────────────────────── + + @action(detail=True, methods=["post"], permission_classes=[IsGroupMember]) + def join(self, request: Request, pk: int | None = None) -> Response: + """Join a reading group (public join link).""" + group = self.get_object() + membership, created = GroupMembership.objects.get_or_create( + group=group, user=request.user + ) + if not created: + return Response({"detail": "Already a member."}, status=status.HTTP_200_OK) + MemberProgress.objects.get_or_create( + membership=membership, + group=group, + user=request.user, + defaults={"current_section": 0, "percentage": 0.0}, + ) + return Response({"detail": "Joined successfully."}, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], permission_classes=[IsGroupMember]) + def leave(self, request: Request, pk: int | None = None) -> Response: + """Leave a reading group.""" + group = self.get_object() + if group.created_by == request.user: + return Response( + {"error": "Group creator cannot leave. Transfer ownership or delete the group."}, + status=status.HTTP_400_BAD_REQUEST, + ) + GroupMembership.objects.filter(group=group, user=request.user).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get"], permission_classes=[IsGroupMember]) + def members(self, request: Request, pk: int | None = None) -> Response: + """List group members with their progress.""" + group = self.get_object() + memberships = ( + group.memberships.select_related("user") + .prefetch_related("progress") + .all() + ) + serializer = GroupMembershipSerializer(memberships, many=True) + return Response(serializer.data) + + # ── Progress ───────────────────────────────────────────────────────── + + @action(detail=True, methods=["get"], permission_classes=[IsGroupMember], + url_path="members/progress") + def members_progress(self, request: Request, pk: int | None = None) -> Response: + """Get progress for all group members. + + Other members' progress shows section label only (no last_position). + Your own progress shows full detail. + """ + group = self.get_object() + progress_qs = ( + MemberProgress.objects.filter(group=group) + .select_related("user") + .order_by("-percentage") + ) + result: list[dict] = [] + for mp in progress_qs: + if mp.user == request.user: + result.append(MemberProgressSerializer(mp).data) + elif mp.is_public: + result.append(MemberProgressPublicSerializer(mp).data) + else: + result.append({ + "user_id": mp.user.id, + "user_email": mp.user.email, + "is_public": False, + "note": "Private", + }) + return Response(result) + + @action(detail=True, methods=["get", "patch"], permission_classes=[IsGroupMember], + url_path="progress") + def my_progress(self, request: Request, pk: int | None = None) -> Response: + """Get or update my own progress in the group.""" + group = self.get_object() + membership = GroupMembership.objects.get(group=group, user=request.user) + progress_obj, _created = MemberProgress.objects.get_or_create( + membership=membership, + group=group, + user=request.user, + ) + + if request.method == "GET": + serializer = MemberProgressSerializer(progress_obj) + return Response(serializer.data) + + # PATCH — update progress + update_serializer = MemberProgressUpdateSerializer(data=request.data) + update_serializer.is_valid(raise_exception=True) + data = update_serializer.validated_data + + progress_obj.update_progress( + current_section=data["current_section"], + percentage=data.get("percentage"), + time_spent_delta=data.get("time_spent_delta", 0), + last_position=data.get("last_position"), + ) + if "is_public" in data: + progress_obj.is_public = data["is_public"] + progress_obj.save(update_fields=["is_public"]) + + serializer = MemberProgressSerializer(progress_obj) + return Response(serializer.data) + + # ── Admin ──────────────────────────────────────────────────────────── + + @action(detail=True, methods=["get"], permission_classes=[IsGroupAdmin], + url_path="progress/summary") + def progress_summary(self, request: Request, pk: int | None = None) -> Response: + """Admin summary of all member progress.""" + group = self.get_object() + progress_qs = MemberProgress.objects.filter(group=group).select_related("user") + + total_members = group.memberships.count() + members_started = progress_qs.filter(current_section__gt=0).count() + members_finished = progress_qs.filter(percentage__gte=100.0).count() + + avg_pct = progress_qs.aggregate(avg=models.Avg("percentage"))["avg"] or 0.0 + avg_time = progress_qs.aggregate(avg=models.Avg("time_spent_seconds"))["avg"] or 0.0 + + member_details: list[dict] = [] + for mp in progress_qs: + member_details.append({ + "user_id": mp.user.id, + "user_email": mp.user.email, + "current_section": mp.current_section, + "percentage": mp.percentage, + "time_spent_seconds": mp.time_spent_seconds, + "is_public": mp.is_public, + "updated_at": mp.updated_at, + }) + + summary_data = { + "group_id": group.id, + "group_name": group.name, + "ebook_title": group.ebook.title, + "total_members": total_members, + "members_started": members_started, + "members_finished": members_finished, + "average_percentage": round(avg_pct, 1), + "average_time_spent_hours": round(avg_time / 3600.0, 1) if avg_time else 0.0, + "member_details": member_details, + } + serializer = AdminProgressSummarySerializer(data=summary_data) + serializer.is_valid(raise_exception=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/App.tsx b/frontend/src/App.tsx index 45ba735..9b188fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); +const ReadingGroupsPage = lazy(() => import("./pages/ReadingGroups").then((m) => ({ default: m.ReadingGroupsPage }))); +const GroupDetailPage = lazy(() => import("./pages/GroupDetail").then((m) => ({ default: m.GroupDetailPage }))); const AuthPage = lazy(() => import("./pages/AuthPage")); @@ -45,6 +47,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts new file mode 100644 index 0000000..ed09936 --- /dev/null +++ b/frontend/src/api/groups.ts @@ -0,0 +1,81 @@ +import api from "./client"; +import type { + AdminProgressSummary, + CreateGroupPayload, + MemberProgressDetail, + MemberProgressEntry, + ReadingGroupDetail, + ReadingGroupSummary, + UpdateProgressPayload, +} from "../types/groups"; + +export const groupsApi = { + /* ── Groups ── */ + + async listGroups(): Promise { + const { data } = await api.get("/groups/"); + if (Array.isArray(data)) return data; + return (data as { results: ReadingGroupSummary[] }).results ?? []; + }, + + async getGroup(id: number): Promise { + const { data } = await api.get(`/groups/${id}/`); + return data; + }, + + async createGroup(payload: CreateGroupPayload): Promise { + const { data } = await api.post("/groups/", payload); + return data; + }, + + async deleteGroup(id: number): Promise { + await api.delete(`/groups/${id}/`); + }, + + /* ── Membership ── */ + + async joinGroup(id: number): Promise<{ detail: string }> { + const { data } = await api.post<{ detail: string }>(`/groups/${id}/join/`); + return data; + }, + + async leaveGroup(id: number): Promise { + await api.post(`/groups/${id}/leave/`); + }, + + /* ── Progress ── */ + + async getMembersProgress(groupId: number): Promise { + const { data } = await api.get( + `/groups/${groupId}/members/progress/`, + ); + return data; + }, + + async getMyProgress(groupId: number): Promise { + const { data } = await api.get( + `/groups/${groupId}/progress/`, + ); + return data; + }, + + async updateMyProgress( + groupId: number, + payload: UpdateProgressPayload, + ): Promise { + const { data } = await api.patch( + `/groups/${groupId}/progress/`, + payload, + ); + return data; + }, + + /* ── Admin ── */ + + async getProgressSummary(groupId: number): Promise { + const { data } = await api.get( + `/groups/${groupId}/progress/summary/`, + ); + return data; + }, +}; \ No newline at end of file diff --git a/frontend/src/pages/GroupDetail.tsx b/frontend/src/pages/GroupDetail.tsx new file mode 100644 index 0000000..3ac5b47 --- /dev/null +++ b/frontend/src/pages/GroupDetail.tsx @@ -0,0 +1,381 @@ +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 type { + AdminProgressSummary, + MemberProgressEntry, + ReadingGroupDetail, +} from "../types/groups"; +import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; + +function isDetail(entry: MemberProgressEntry): entry is { user_id: number; is_public: boolean; id: number } { + return "id" in entry && "is_public" in entry; +} + +export function GroupDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const isMobile = useMediaQuery(BREAKPOINTS.md); + + const [group, setGroup] = useState(null); + const [progressEntries, setProgressEntries] = useState([]); + const [adminSummary, setAdminSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState<"progress" | "admin">("progress"); + + const isAdmin = group?.members.some( + (m) => m.user_id === (group as ReadingGroupDetail & { _myUserId?: number })._myUserId && m.role === "admin", + ); + + const loadGroup = useCallback(async () => { + if (!id) return; + setLoading(true); + setError(null); + try { + const groupId = Number(id); + if (Number.isNaN(groupId)) { + setError(t("groupDetail.invalidId") ?? "Invalid group ID"); + return; + } + const [groupData, progressData] = await Promise.all([ + groupsApi.getGroup(groupId), + groupsApi.getMembersProgress(groupId).catch(() => [] as MemberProgressEntry[]), + ]); + setGroup(groupData); + setProgressEntries(progressData); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load group"); + } finally { + setLoading(false); + } + }, [id, t]); + + const loadAdminSummary = useCallback(async () => { + if (!id || !isAdmin) return; + try { + const summary = await groupsApi.getProgressSummary(Number(id)); + setAdminSummary(summary); + } catch { + // Admin summary is optional + } + }, [id, isAdmin]); + + useEffect(() => { + void loadGroup(); + }, [loadGroup]); + + useEffect(() => { + if (tab === "admin") { + void loadAdminSummary(); + } + }, [tab, loadAdminSummary]); + + const handleJoin = useCallback(async () => { + if (!id) return; + try { + await groupsApi.joinGroup(Number(id)); + void loadGroup(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to join"); + } + }, [id, loadGroup]); + + const handleLeave = useCallback(async () => { + if (!id) return; + try { + await groupsApi.leaveGroup(Number(id)); + navigate("/groups"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to leave"); + } + }, [id, navigate]); + + const containerStyle: React.CSSProperties = { + maxWidth: 720, + margin: "0 auto", + padding: isMobile ? 16 : 24, + minHeight: "100vh", + background: "#f8f9fa", + }; + + const backButtonStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: isMobile ? "10px 16px" : "8px 16px", + borderRadius: 8, + border: "1px solid #e5e7eb", + background: "#fff", + color: "#374151", + fontSize: isMobile ? 15 : 14, + cursor: "pointer", + marginBottom: 20, + minHeight: 44, + }; + + const tabBarStyle: React.CSSProperties = { + display: "flex", + gap: 0, + marginBottom: 20, + borderBottom: "2px solid #e5e7eb", + }; + + const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: "10px 20px", + fontSize: 14, + fontWeight: active ? 600 : 400, + color: active ? "#4f46e5" : "#6b7280", + background: "transparent", + border: "none", + borderBottom: active ? "2px solid #4f46e5" : "2px solid transparent", + cursor: "pointer", + marginBottom: -2, + }); + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + if (error || !group) { + return ( +
+ +
+
😕
+

+ {error ?? (t("groupDetail.notFound") ?? "Group not found")} +

+
+
+ ); + } + + const isMember = group.members.some((m) => { + // Determine membership by matching progress data — user_id can come from members list + // We check if there's a progress entry with an "id" (indicates own data) + return progressEntries.some((e) => "id" in e); + }); + + return ( +
+ + + {/* Header */} +
+

+ {group.name} +

+

+ {group.ebook_title} {group.ebook_author ? `— ${group.ebook_author}` : ""} +

+
+ + {/* Group actions */} +
+ {!isMember ? ( + + ) : ( + <> + + ✓ {t("groupDetail.member") ?? "Member"} + + {group.created_by_email !== undefined /* can't check identity here, but leave is always available for non-creators */ && ( + + )} + + )} +
+ + {group.description && ( +

+ {group.description} +

+ )} + + {/* Tabs */} +
+ + {isAdmin && ( + + )} +
+ + {/* Progress tab */} + {tab === "progress" && ( +
+ {progressEntries.length === 0 ? ( +
+

{t("groupDetail.noProgress") ?? "No progress data yet."}

+
+ ) : ( + progressEntries.map((entry) => { + const isPrivate = "is_public" in entry && entry.is_public === false && "note" in entry; + const hasDetail = isDetail(entry); + + return ( +
+
+ + {entry.user_email} + {hasDetail && " (you)"} + + {isPrivate ? ( + + 🔒 {t("groupDetail.private") ?? "Private"} + + ) : "section_label" in entry && ( + + {entry.section_label} + + )} +
+ + {!isPrivate && "percentage" in entry && ( +
+
+
+
+ + {Math.round(entry.percentage)}% + +
+ )} + + {hasDetail && entry.last_position && Object.keys(entry.last_position).length > 0 && ( +
+ {t("groupDetail.lastPosition") ?? "Last position"}: {JSON.stringify(entry.last_position)} +
+ )} +
+ ); + }) + )} +
+ )} + + {/* Admin tab */} + {tab === "admin" && adminSummary && ( +
+
+ + + + + +
+ +

+ {t("groupDetail.memberDetails") ?? "Member Details"} +

+ {adminSummary.member_details.map((m) => ( +
+
+ {m.user_email} + + {m.is_public ? "👁" : "🔒"} + +
+
+ Section {m.current_section} + {Math.round(m.percentage)}% + ⏱ {Math.round(m.time_spent_seconds / 60)}m +
+
+ ))} +
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index ca20f08..e835c6f 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -242,6 +242,7 @@ export function LibraryPage() { <> + @@ -249,6 +250,7 @@ export function LibraryPage() { <> + diff --git a/frontend/src/pages/ReadingGroups.tsx b/frontend/src/pages/ReadingGroups.tsx new file mode 100644 index 0000000..4f33012 --- /dev/null +++ b/frontend/src/pages/ReadingGroups.tsx @@ -0,0 +1,288 @@ +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 { booksApi } from "../api/books"; +import type { ReadingGroupSummary } from "../types/groups"; +import type { EBookListItem } from "../types/book"; +import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; + +export function ReadingGroupsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useMediaQuery(BREAKPOINTS.md); + + const [groups, setGroups] = useState([]); + const [ebooks, setEbooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [createName, setCreateName] = useState(""); + const [createEbook, setCreateEbook] = useState(null); + const [createDesc, setCreateDesc] = useState(""); + const [creating, setCreating] = useState(false); + + const loadGroups = useCallback(async () => { + try { + const data = await groupsApi.listGroups(); + setGroups(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load groups"); + } finally { + setLoading(false); + } + }, []); + + const loadEbooks = useCallback(async () => { + try { + const data = await booksApi.getEBooks(); + setEbooks(data); + } catch { + // Ebook list is optional; groups page still works without it + } + }, []); + + useEffect(() => { + void loadGroups(); + void loadEbooks(); + }, [loadGroups, loadEbooks]); + + const handleCreate = useCallback(async () => { + if (!createName.trim() || createEbook === null) return; + setCreating(true); + try { + await groupsApi.createGroup({ + name: createName.trim(), + ebook: createEbook, + description: createDesc.trim(), + }); + setShowCreate(false); + setCreateName(""); + setCreateEbook(null); + setCreateDesc(""); + void loadGroups(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create group"); + } finally { + setCreating(false); + } + }, [createName, createEbook, createDesc, loadGroups]); + + const containerStyle: React.CSSProperties = { + maxWidth: 720, + margin: "0 auto", + padding: isMobile ? 16 : 24, + minHeight: "100vh", + background: "#f8f9fa", + }; + + const headerBar: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 24, + }; + + const cardStyle: React.CSSProperties = { + background: "#fff", + borderRadius: 12, + padding: isMobile ? 14 : 18, + marginBottom: 12, + cursor: "pointer", + boxShadow: "0 1px 4px rgba(0,0,0,0.06)", + border: "1px solid #e5e7eb", + }; + + if (loading) { + return ( +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+
+

+ 📚 {t("readingGroups.title") ?? "Reading Groups"} +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Create modal */} + {showCreate && ( +
+
+

+ {t("readingGroups.createTitle") ?? "Create Reading Group"} +

+ + + setCreateName(e.target.value)} + placeholder={t("readingGroups.namePlaceholder") ?? "e.g., Book Club June"} + style={{ + width: "100%", padding: "10px 14px", borderRadius: 8, border: "1px solid #d1d5db", + fontSize: 14, marginBottom: 14, boxSizing: "border-box", + }} + /> + + + + + +