/api/user/summary: streak.activeToday and streak.lost for the app's lit / at-risk / frozen flame

activeToday: activity recorded today (Tehran date). lost: length of a streak that broke (last active day
before yesterday), so the app can show it frozen. Same rule as effective_current_streak; the weekly report
keeps its own two-field streak serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:27:14 +03:30
parent 2e87a81cc5
commit 9024c6b8d8
4 changed files with 48 additions and 4 deletions

View File

@@ -6,6 +6,14 @@ class StreakSummarySerializer(serializers.Serializer):
longest = serializers.IntegerField() longest = serializers.IntegerField()
class UserStreakSerializer(StreakSummarySerializer):
"""زنجیره در /api/user/summary: به‌علاوه‌ی وضعیتی که خانه با آن آتش/در خطر/یخ‌زده را نشان می‌دهد."""
activeToday = serializers.BooleanField(help_text='امروز (وقتِ تهران) فعالیتی ثبت شده؛ زنجیره امروز روشن است.')
lost = serializers.IntegerField(
help_text='طولِ زنجیره‌ای که با نیامدنِ دیروز شکست؛ صفر یعنی چیزی نشکسته. اپ آن را «یخ‌زده» نشان می‌دهد.',
)
class UserSummaryResponseSerializer(serializers.Serializer): class UserSummaryResponseSerializer(serializers.Serializer):
userId = serializers.CharField() userId = serializers.CharField()
name = serializers.CharField() name = serializers.CharField()
@@ -13,7 +21,7 @@ class UserSummaryResponseSerializer(serializers.Serializer):
gradeId = serializers.IntegerField(allow_null=True) gradeId = serializers.IntegerField(allow_null=True)
xp = serializers.IntegerField() xp = serializers.IntegerField()
coins = serializers.IntegerField() coins = serializers.IntegerField()
streak = StreakSummarySerializer() streak = UserStreakSerializer()
subscription = serializers.CharField(allow_null=True) subscription = serializers.CharField(allow_null=True)

View File

@@ -57,3 +57,21 @@ def effective_current_streak(streak, today=None):
if last is None or last < today - timedelta(days=1): if last is None or last < today - timedelta(days=1):
return 0 return 0
return streak.current_streak return streak.current_streak
def streak_state(streak, today=None):
"""
وضعیتِ زنجیره برای نمایش: (current, active_today, lost).
lost طولِ زنجیره‌ای است که شکسته (آخرین روزِ فعال پیش از دیروز)؛ current_streakِ دیتابیس تا فعالیتِ بعدی
همان عددِ قبلی را نگه می‌دارد (record_activity آن را به ۱ برمی‌گرداند)، پس همین عدد است.
"""
if streak is None:
return 0, False, 0
today = today or timezone.localdate()
last = streak.active_days.order_by('-date').values_list('date', flat=True).first()
if last is None:
return 0, False, 0
if last < today - timedelta(days=1):
return 0, False, streak.current_streak
return streak.current_streak, last == today, 0

View File

@@ -15,7 +15,7 @@ from accounts.authentication import issue_access_token
from accounts.models import User from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, Unit from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import StreakRecord from progress.models import StreakRecord
from progress.services import effective_current_streak, record_activity from progress.services import effective_current_streak, record_activity, streak_state
class StreakTests(TestCase): class StreakTests(TestCase):
@@ -50,6 +50,19 @@ class StreakTests(TestCase):
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0) self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
self.assertEqual(effective_current_streak(None), 0) self.assertEqual(effective_current_streak(None), 0)
def test_state_tells_lit_at_risk_and_frozen_apart(self):
"""اپ سه حالت نشان می‌دهد: امروز روشن، امروز هنوز نه (در خطر)، و شکسته («یخ‌زده» با طولِ قبلی)."""
for day in (20, 21, 22):
streak = record_activity(1, today=date(2026, 9, day))
self.assertEqual(streak_state(streak, today=date(2026, 9, 22)), (3, True, 0))
self.assertEqual(streak_state(streak, today=date(2026, 9, 23)), (3, False, 0))
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (0, False, 3))
self.assertEqual(streak_state(None), (0, False, 0))
# فعالیتِ بعد از شکستن زنجیره‌ی تازه می‌سازد و «یخ» می‌رود.
streak = record_activity(1, today=date(2026, 9, 24))
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (1, True, 0))
class ActivityFeedsStreakAndLeaderboardTests(TestCase): class ActivityFeedsStreakAndLeaderboardTests(TestCase):
def setUp(self): def setUp(self):
@@ -74,6 +87,8 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase):
summary = self.client.get('/api/user/summary', **self.auth).json() summary = self.client.get('/api/user/summary', **self.auth).json()
self.assertEqual(summary['streak']['current'], 1) self.assertEqual(summary['streak']['current'], 1)
self.assertIs(summary['streak']['activeToday'], True)
self.assertEqual(summary['streak']['lost'], 0)
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json() calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
self.assertEqual(len(calendar['activeDays']), 1) self.assertEqual(len(calendar['activeDays']), 1)

View File

@@ -8,7 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import Character, StreakRecord, UserHearts, UserStats from .models import Character, StreakRecord, UserHearts, UserStats
from .services import effective_current_streak from .services import effective_current_streak, streak_state
from .serializers import ( from .serializers import (
CharacterListResponseSerializer, CharacterListResponseSerializer,
LeaderboardResponseSerializer, LeaderboardResponseSerializer,
@@ -47,6 +47,7 @@ class UserSummaryView(APIView):
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
stats = UserStats.objects.filter(user_id=user.id).first() stats = UserStats.objects.filter(user_id=user.id).first()
streak = StreakRecord.objects.filter(user_id=user.id).first() streak = StreakRecord.objects.filter(user_id=user.id).first()
current, active_today, lost = streak_state(streak)
data = { data = {
'userId': str(user.id), 'userId': str(user.id),
@@ -56,8 +57,10 @@ class UserSummaryView(APIView):
'xp': stats.xp if stats else 0, 'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0, 'coins': stats.coins if stats else 0,
'streak': { 'streak': {
'current': effective_current_streak(streak), 'current': current,
'longest': streak.longest_streak if streak else 0, 'longest': streak.longest_streak if streak else 0,
'activeToday': active_today,
'lost': lost,
}, },
# عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود # عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
# (از زمانی که اپِ subscription هنوز وجود نداشت). # (از زمانی که اپِ subscription هنوز وجود نداشت).