From 9f8d53d8ac72320301762c33c47102d8b8c5faf7 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 13:46:38 +0100 Subject: [PATCH 1/2] Fix streak to only update on actual practice activities Previously, the daily streak was updated on ANY authenticated API call (via the @requires_session decorator), causing streaks to continue even when users just opened the app without practicing. Changes: - Add reset_streak_if_broken() to UserLanguage - resets streak to 0 if user hasn't practiced in 2+ days (called on login) - Change @requires_session to call reset_streak_if_broken() instead of update_streak_if_needed() - only resets broken streaks, doesn't increment - Add update_user_streak() helper function for practice endpoints - Add streak updates to actual practice endpoints: - /report_exercise_outcome (completing exercises) - /reading_session_start (starting to read) - /listening_session_start (starting to listen) - /get_one_translation (translating words while reading) Co-Authored-By: Claude Opus 4.5 --- zeeguu/api/endpoints/exercises.py | 7 ++++-- zeeguu/api/endpoints/listening_sessions.py | 6 ++++- zeeguu/api/endpoints/reading_sessions.py | 6 ++++- zeeguu/api/endpoints/translation.py | 5 ++++- zeeguu/api/utils/__init__.py | 2 +- zeeguu/api/utils/route_wrappers.py | 26 ++++++++++++++++++++-- zeeguu/core/model/user.py | 9 +------- zeeguu/core/model/user_language.py | 19 ++++++++++++++++ 8 files changed, 64 insertions(+), 16 deletions(-) diff --git a/zeeguu/api/endpoints/exercises.py b/zeeguu/api/endpoints/exercises.py index dabb5b053..67d1c16b1 100644 --- a/zeeguu/api/endpoints/exercises.py +++ b/zeeguu/api/endpoints/exercises.py @@ -6,7 +6,7 @@ from zeeguu.core.model.bookmark import Bookmark from zeeguu.core.model.user import User -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean from . import api, db_session @@ -212,7 +212,7 @@ def report_exercise_outcome(): user_word = UserWord.query.get(user_word_id) if not user_word: return "FAIL - UserWord not found" - + user_word.report_exercise_outcome( db_session, source, @@ -222,6 +222,9 @@ def report_exercise_outcome(): other_feedback, ) + # Update daily streak when user completes an exercise + update_user_streak() + return "OK" except Exception as e: traceback.print_exc() diff --git a/zeeguu/api/endpoints/listening_sessions.py b/zeeguu/api/endpoints/listening_sessions.py index 25706adce..fd1c58933 100644 --- a/zeeguu/api/endpoints/listening_sessions.py +++ b/zeeguu/api/endpoints/listening_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserListeningSession @@ -20,6 +20,10 @@ def listening_session_start(): session = UserListeningSession._create_new_session( db_session, flask.g.user_id, daily_audio_lesson_id, platform=platform ) + + # Update daily streak when user starts listening + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/reading_sessions.py b/zeeguu/api/endpoints/reading_sessions.py index 44181310d..84ff24d42 100644 --- a/zeeguu/api/endpoints/reading_sessions.py +++ b/zeeguu/api/endpoints/reading_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserReadingSession from datetime import datetime @@ -26,6 +26,10 @@ def reading_session_start(): session = UserReadingSession(flask.g.user_id, article_id, datetime.now(), reading_source, platform) db_session.add(session) db_session.commit() + + # Update daily streak when user starts reading + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 5e1b6c126..12871566a 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -8,7 +8,7 @@ from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.core.translation_services.translator import ( get_next_results, contribute_trans, @@ -158,6 +158,9 @@ def get_one_translation(from_lang_code, to_lang_code): f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) + # Update daily streak when user translates a word (active reading practice) + update_user_streak() + return json_result( { "translation": t1["translation"], diff --git a/zeeguu/api/utils/__init__.py b/zeeguu/api/utils/__init__.py index c2a7a07db..45891506c 100644 --- a/zeeguu/api/utils/__init__.py +++ b/zeeguu/api/utils/__init__.py @@ -1,3 +1,3 @@ -from .route_wrappers import cross_domain, requires_session +from .route_wrappers import cross_domain, requires_session, update_user_streak from .json_result import json_result from .parse_json_boolean import parse_json_boolean diff --git a/zeeguu/api/utils/route_wrappers.py b/zeeguu/api/utils/route_wrappers.py index bed68fe8d..115889fad 100644 --- a/zeeguu/api/utils/route_wrappers.py +++ b/zeeguu/api/utils/route_wrappers.py @@ -89,12 +89,13 @@ def wrapped_view(*args, **kwargs): if user: user.update_last_seen_if_needed(db.session) - # Update per-language streak for the user's current learned language + # Reset streak if user hasn't practiced in 2+ days + # (streak is only incremented in actual practice endpoints) if user.learned_language: user_language = UserLanguage.find_or_create( db.session, user, user.learned_language ) - user_language.update_streak_if_needed(db.session) + user_language.reset_streak_if_broken(db.session) # Commit immediately since this is a simple timestamp update db.session.commit() @@ -193,3 +194,24 @@ def wrapped_view(*args, **kwargs): return wrapped_view +def update_user_streak(): + """ + Call this in practice endpoints to update the user's daily streak. + Should be called when user performs actual practice activities: + - Completing exercises + - Reading articles (creating bookmarks/translations) + - Listening to audio lessons + """ + from zeeguu.core.model import User + from zeeguu.core.model.user_language import UserLanguage + from zeeguu.core.model.db import db + + user = User.find_by_id(flask.g.user_id) + if user and user.learned_language: + user_language = UserLanguage.find_or_create( + db.session, user, user.learned_language + ) + user_language.update_streak_if_needed(db.session) + db.session.commit() + + diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264a..61b5848d6 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -401,19 +401,12 @@ def active_during_recent(self, days: int = 30): def update_last_seen_if_needed(self, session=None): """ Update last_seen timestamp, but only once per day to minimize database writes. - Also maintains the daily_streak counter. + Note: daily_streak is now tracked per-language in UserLanguage model. """ now = datetime.datetime.now() # Only update if last_seen is None or it's a different day if not self.last_seen or self.last_seen.date() < now.date(): - if not self.last_seen: - self.daily_streak = 1 - elif self.last_seen.date() == now.date() - datetime.timedelta(days=1): - self.daily_streak = (self.daily_streak or 0) + 1 - else: - self.daily_streak = 1 - self.last_seen = now if session: session.add(self) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 128e444ea..5cba5cef1 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -123,6 +123,7 @@ def all_for_user(cls, user): def update_streak_if_needed(self, session=None): """ Update last_practiced timestamp and daily_streak counter for this language. + Call this when user performs actual practice (exercises, reading, etc.). Only updates once per day to minimize database writes. """ now = datetime.datetime.now() @@ -138,3 +139,21 @@ def update_streak_if_needed(self, session=None): self.last_practiced = now if session: session.add(self) + + def reset_streak_if_broken(self, session=None): + """ + Reset streak to 0 if user hasn't practiced since yesterday. + Call this on login/session validation to ensure streak reflects reality. + Does NOT update last_practiced or increment streak. + """ + if not self.last_practiced: + return + + now = datetime.datetime.now() + yesterday = now.date() - datetime.timedelta(days=1) + + # If last practice was before yesterday, streak is broken + if self.last_practiced.date() < yesterday: + self.daily_streak = 0 + if session: + session.add(self) From 315446bb465ab02da6f7c4ecf3ea3426b330f79d Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 17:15:06 +0100 Subject: [PATCH 2/2] Add max_streak tracking to preserve best streak history Saves max_streak and max_streak_date before resetting daily_streak, so users can see their all-time best even after breaking a streak. Co-Authored-By: Claude Opus 4.5 --- .../26-02-26--add_max_streak_to_user_language.sql | 10 ++++++++++ zeeguu/api/endpoints/daily_streak.py | 6 +++++- zeeguu/core/model/user_language.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tools/migrations/26-02-26--add_max_streak_to_user_language.sql diff --git a/tools/migrations/26-02-26--add_max_streak_to_user_language.sql b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql new file mode 100644 index 000000000..8d96a32eb --- /dev/null +++ b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql @@ -0,0 +1,10 @@ +-- Add max_streak tracking to user_language table +ALTER TABLE user_language +ADD COLUMN max_streak INT NOT NULL DEFAULT 0, +ADD COLUMN max_streak_date DATETIME NULL; + +-- Seed max_streak from current daily_streak for users with active streaks +UPDATE user_language +SET max_streak = daily_streak, + max_streak_date = last_practiced +WHERE daily_streak > 0; diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index ee4c4edf9..45cfa1896 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -14,4 +14,8 @@ def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) - return json_result({"daily_streak": user_language.daily_streak or 0}) + return json_result({ + "daily_streak": user_language.daily_streak or 0, + "max_streak": user_language.max_streak or 0, + "max_streak_date": user_language.max_streak_date.strftime("%Y-%m-%d") if user_language.max_streak_date else None, + }) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5cba5cef1..bd96739b3 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -48,6 +48,8 @@ class UserLanguage(db.Model): last_practiced = Column(DateTime, nullable=True) daily_streak = Column(Integer, default=0) + max_streak = Column(Integer, default=0) + max_streak_date = Column(DateTime, nullable=True) def __init__( self, @@ -134,9 +136,12 @@ def update_streak_if_needed(self, session=None): elif self.last_practiced.date() == now.date() - datetime.timedelta(days=1): self.daily_streak = (self.daily_streak or 0) + 1 else: + # Gap in practice - save max before resetting + self._update_max_streak_if_needed() self.daily_streak = 1 self.last_practiced = now + self._update_max_streak_if_needed() if session: session.add(self) @@ -154,6 +159,13 @@ def reset_streak_if_broken(self, session=None): # If last practice was before yesterday, streak is broken if self.last_practiced.date() < yesterday: + self._update_max_streak_if_needed() self.daily_streak = 0 if session: session.add(self) + + def _update_max_streak_if_needed(self): + """Update max_streak if current streak exceeds it.""" + if self.daily_streak > (self.max_streak or 0): + self.max_streak = self.daily_streak + self.max_streak_date = self.last_practiced