diff --git a/apps/api/tests/test_dashboard_streak.py b/apps/api/tests/test_dashboard_streak.py new file mode 100644 index 0000000..95b760e --- /dev/null +++ b/apps/api/tests/test_dashboard_streak.py @@ -0,0 +1,182 @@ +""" +Tests for real-time streak calculation in DashboardView. + +These tests verify that the dashboard shows the correct current streak +even when user hasn't written for multiple days (streak should be 0). +""" +import pytest +from datetime import timedelta +from freezegun import freeze_time +from django.test import override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.tests.factories import UserFactory +from apps.journal.tests.factories import EntryFactory + + +@pytest.mark.integration +@pytest.mark.streak +@pytest.mark.django_db +class TestDashboardStreakRealTime: + """Tests for real-time streak calculation in dashboard.""" + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_shows_zero_after_gap(self): + """Streak should be 0 if last entry was 2+ days ago.""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + # Create entries for 2 consecutive days (3 days ago and 2 days ago) + # Last entry is from 2025-01-13 (2 days before frozen time) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=2)) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=3)) + + # User model still shows old streak (this simulates the bug) + user.current_streak = 2 + user.save() + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + + # Dashboard should show 0 streak (not the cached value from user model) + assert response.data['stats']['current_streak'] == 0 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_shows_correct_value_when_written_today(self): + """Streak should include today when entry exists for today.""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + # Create entries for 3 consecutive days ending today + EntryFactory(user=user, created_at=timezone.now()) # Today + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=1)) # Yesterday + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=2)) # 2 days ago + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + assert response.data['stats']['current_streak'] == 3 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_shows_correct_value_when_written_yesterday(self): + """Streak should continue if last entry was yesterday (haven't written today yet).""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + # Create entries for 3 consecutive days ending yesterday + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=1)) # Yesterday + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=2)) # 2 days ago + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=3)) # 3 days ago + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + # Streak is still 3 because yesterday was written (user can still continue today) + assert response.data['stats']['current_streak'] == 3 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_shows_zero_for_no_entries(self): + """Streak should be 0 when user has no entries.""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + assert response.data['stats']['current_streak'] == 0 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_longest_streak_unchanged_from_user_model(self): + """Longest streak should still come from user model (it's historical).""" + user = UserFactory(timezone='Europe/Prague', longest_streak=10) + client = APIClient() + client.force_authenticate(user=user) + + # No entries, but longest_streak should still be preserved + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + assert response.data['stats']['longest_streak'] == 10 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_with_gap_in_past_but_recent_activity(self): + """Streak should only count consecutive days up to today/yesterday.""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + # Old streak: 5 days ago to 3 days ago + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=3)) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=4)) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=5)) + + # Gap on days 2 and 1 ago + + # Recent entry: today (new streak) + EntryFactory(user=user, created_at=timezone.now()) + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + # Current streak is just 1 (only today) + assert response.data['stats']['current_streak'] == 1 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 23:00:00', tz_offset=0) + def test_streak_respects_user_timezone(self): + """Streak calculation should respect user's timezone for 'today'.""" + # User in Tokyo (UTC+9) - it's already 2025-01-16 08:00 there + user = UserFactory(timezone='Asia/Tokyo') + client = APIClient() + client.force_authenticate(user=user) + + # Entry at 2025-01-15 15:00 UTC = 2025-01-16 00:00 Tokyo time + # This counts as "today" for the Tokyo user + EntryFactory(user=user, created_at=timezone.now() - timedelta(hours=8)) + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + # Should be streak of 1 (today in Tokyo timezone) + assert response.data['stats']['current_streak'] == 1 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 01:00:00', tz_offset=0) + def test_streak_respects_user_timezone_negative(self): + """Streak calculation should respect user's timezone for 'yesterday'.""" + # User in Los Angeles (UTC-8) - it's still 2025-01-14 17:00 there + user = UserFactory(timezone='America/Los_Angeles') + client = APIClient() + client.force_authenticate(user=user) + + # Entry from "yesterday" in LA timezone (which is 2 days ago UTC) + # 2025-01-13 17:00 UTC = 2025-01-14 09:00 LA time (yesterday for LA user) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=1, hours=8)) + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + # Should be streak of 1 (yesterday in LA timezone, user can still write today) + assert response.data['stats']['current_streak'] == 1 + + @override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': [], 'DEFAULT_THROTTLE_RATES': {}}) + @freeze_time('2025-01-15 12:00:00', tz_offset=0) + def test_streak_ignores_empty_entries(self): + """Entries with 0 words should not count toward streak.""" + user = UserFactory(timezone='Europe/Prague') + client = APIClient() + client.force_authenticate(user=user) + + # Entry with content (word_count > 0) + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=1), content='some words here') + # Entry without content (word_count = 0) - created with empty content + EntryFactory(user=user, created_at=timezone.now() - timedelta(days=2), content='') + + response = client.get('/api/v1/dashboard/') + assert response.status_code == 200 + # Only yesterday counts (day before was empty) + assert response.data['stats']['current_streak'] == 1 diff --git a/apps/api/views.py b/apps/api/views.py index a310f95..f1013d1 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -26,6 +26,7 @@ get_user_local_date, get_today_date_range, parse_tags, + recalculate_user_streak, ) from apps.api.serializers import ( EntrySerializer, @@ -266,11 +267,15 @@ def get(self, request): )) ) + # Calculate current streak in real-time (not from user model cache) + # allow_yesterday=True keeps streak alive if user wrote yesterday + current_streak = recalculate_user_streak(user, allow_yesterday=True)['current_streak'] + stats = { 'today_words': stats_aggregation['today_words'] or 0, 'daily_goal': user.daily_word_goal, 'goal_progress': min(100, int((stats_aggregation['today_words'] or 0) / user.daily_word_goal * 100)) if user.daily_word_goal > 0 else 0, - 'current_streak': user.current_streak, + 'current_streak': current_streak, 'longest_streak': user.longest_streak, 'total_entries': stats_aggregation['total_entries'] or 0, 'total_words': stats_aggregation['total_words'] or 0, diff --git a/apps/journal/utils.py b/apps/journal/utils.py index 9748b7d..1ffab4a 100644 --- a/apps/journal/utils.py +++ b/apps/journal/utils.py @@ -100,39 +100,55 @@ def update_user_streak(user, entry_created_at): user.save(update_fields=['current_streak', 'longest_streak', 'last_entry_date']) -def recalculate_user_streak(user): +def recalculate_user_streak(user, allow_yesterday=False): """ Recalculate streak from scratch based on entry history. - + Useful for: - Data verification - Fixing corrupted streak data - Admin tools - + - Real-time dashboard streak (with allow_yesterday=True) + + Args: + user: User object with timezone field + allow_yesterday: If True, treat yesterday as a valid anchor for the + current streak (the user can still write today). If False, + the streak is 0 unless the user wrote today. + Returns: dict with current_streak and longest_streak """ from .models import Entry - + # Only include entries with actual content (word_count > 0) # This matches the signal logic for streak updates entries = Entry.objects.filter(user=user, word_count__gt=0).order_by('created_at') - + if not entries.exists(): return {'current_streak': 0, 'longest_streak': 0} - + # Get all unique dates (in user's timezone) dates = sorted(set( get_user_local_date(entry.created_at, user.timezone) for entry in entries )) - - # Calculate current streak (working backwards from today) + + # Calculate current streak (working backwards from anchor date) today = get_user_local_date(timezone.now(), user.timezone) + anchor = today + + # When allow_yesterday is set, treat yesterday as a valid streak anchor + # so the dashboard shows the streak as still alive until end of today + if allow_yesterday and today not in set(dates): + yesterday = today - timedelta(days=1) + if yesterday in set(dates): + anchor = yesterday + current_streak = 0 - + for i in range(len(dates) - 1, -1, -1): - expected_date = today - timedelta(days=current_streak) + expected_date = anchor - timedelta(days=current_streak) if dates[i] == expected_date: current_streak += 1 else: diff --git a/frontend/src/pages/EntryEditorPage.tsx b/frontend/src/pages/EntryEditorPage.tsx index 8db8105..e2d8fd5 100644 --- a/frontend/src/pages/EntryEditorPage.tsx +++ b/frontend/src/pages/EntryEditorPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Flame, Sparkles } from 'lucide-react'; import { AppLayout } from '../components/layout/AppLayout'; @@ -22,12 +22,13 @@ export function EntryEditorPage() { const { t, language } = useLanguage(); const { entry, isLoading, error, save, remove } = useEntry(id); - const { data: dashboardData } = useDashboard(); + const { data: dashboardData, refresh: refreshDashboard } = useDashboard(); const [content, setContent] = useState(''); const [moodRating, setMoodRating] = useState(null); const [tags, setTags] = useState([]); const [isSaving, setIsSaving] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const streakRefreshedRef = useRef(false); const [zenMode, setZenMode] = useState(() => { try { const stored = localStorage.getItem('zenMode'); @@ -46,8 +47,14 @@ export function EntryEditorPage() { if (!id && newId) { navigate(`/entries/${newId}`, { replace: true }); } + + // Refresh dashboard once after first successful save to update streak + if (!streakRefreshedRef.current) { + streakRefreshedRef.current = true; + refreshDashboard(); + } }, - [id, navigate] + [id, navigate, refreshDashboard] ); const { save: autoSave, isSaving: isAutoSaving, lastSaved } = useAutoSave(id, { diff --git a/frontend/src/pages/TodayEntryPage.tsx b/frontend/src/pages/TodayEntryPage.tsx index fbbd7fb..24cd3eb 100644 --- a/frontend/src/pages/TodayEntryPage.tsx +++ b/frontend/src/pages/TodayEntryPage.tsx @@ -24,7 +24,7 @@ export function TodayEntryPage() { const { t, language } = useLanguage(); const { entry, isLoading, error, exists } = useTodayEntry(); - const { data: dashboardData } = useDashboard(); + const { data: dashboardData, refresh: refreshDashboard } = useDashboard(); const [content, setContent] = useState(''); const [moodRating, setMoodRating] = useState(null); const [tags, setTags] = useState([]); @@ -43,8 +43,9 @@ export function TodayEntryPage() { const retryCountRef = useRef(0); const retryTimeoutRef = useRef(null); const isCreatingEntryRef = useRef(isCreatingEntry); + const streakRefreshedRef = useRef(false); - // Auto-save functionality (bez refresh - není potřeba) + // Auto-save functionality const onSaveSuccess = useCallback(() => { // Reset retry count on successful save retryCountRef.current = 0; @@ -54,7 +55,13 @@ export function TodayEntryPage() { setIsCreatingEntry(false); isCreatingEntryRef.current = false; } - }, []); + + // Refresh dashboard once after first successful save to update streak + if (!streakRefreshedRef.current) { + streakRefreshedRef.current = true; + refreshDashboard(); + } + }, [refreshDashboard]); const onSaveError = useCallback((err: Error, isCreating: boolean) => { // Always reset isCreatingEntry to unblock future attempts