Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions apps/api/tests/test_dashboard_streak.py
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +157 to +159
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix incorrect comment - timezone math is wrong.

The comment states "2025-01-13 17:00 UTC = 2025-01-14 09:00 LA time" but LA is UTC-8, so the correct conversion is:

  • 2025-01-13 17:00 UTC = 2025-01-13 09:00 LA time

The test assertion is still correct (entry is on Jan 13 LA time, which is "yesterday" when LA's current date is Jan 14), but the comment is misleading.

📝 Suggested comment fix
         # 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)
+        # 2025-01-13 17:00 UTC = 2025-01-13 09:00 LA time (yesterday for LA user since LA is on Jan 14)
         EntryFactory(user=user, created_at=timezone.now() - timedelta(days=1, hours=8))
🤖 Prompt for AI Agents
In `@apps/api/tests/test_dashboard_streak.py` around lines 156 - 158, Update the
misleading inline comment above the EntryFactory call in
test_dashboard_streak.py: replace the incorrect timezone conversion ("2025-01-13
17:00 UTC = 2025-01-14 09:00 LA time") with the correct conversion reflecting LA
= UTC-8 (e.g., "2025-01-13 17:00 UTC = 2025-01-13 09:00 LA time") so the comment
correctly describes why EntryFactory(user=user, created_at=timezone.now() -
timedelta(days=1, hours=8)) yields an entry on Jan 13 in LA time.


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
7 changes: 6 additions & 1 deletion apps/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
get_user_local_date,
get_today_date_range,
parse_tags,
recalculate_user_streak,
)
from apps.api.serializers import (
EntrySerializer,
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 26 additions & 10 deletions apps/journal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/pages/EntryEditorPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<number | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const streakRefreshedRef = useRef(false);
const [zenMode, setZenMode] = useState(() => {
try {
const stored = localStorage.getItem('zenMode');
Expand All @@ -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, {
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/pages/TodayEntryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const [tags, setTags] = useState<string[]>([]);
Expand All @@ -43,8 +43,9 @@ export function TodayEntryPage() {
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef<number | null>(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;
Expand All @@ -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
Expand Down