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
10 changes: 10 additions & 0 deletions tools/migrations/26-02-26--add_max_streak_to_user_language.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 5 additions & 1 deletion zeeguu/api/endpoints/daily_streak.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
7 changes: 5 additions & 2 deletions zeeguu/api/endpoints/exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion zeeguu/api/endpoints/listening_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))


Expand Down
6 changes: 5 additions & 1 deletion zeeguu/api/endpoints/reading_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))


Expand Down
5 changes: 4 additions & 1 deletion zeeguu/api/endpoints/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion zeeguu/api/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 24 additions & 2 deletions zeeguu/api/utils/route_wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()


9 changes: 1 addition & 8 deletions zeeguu/core/model/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions zeeguu/core/model/user_language.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,6 +125,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()
Expand All @@ -133,8 +136,36 @@ 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)

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._update_max_streak_if_needed()
self.daily_streak = 0
if session:
session.add(self)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@xXPinkmagicXx @klnyzzz33 @gabortodor -- folks, i know we'll have badges for streak lengths. but here, where we reset streaks - should we also save the longest streak? if it's between 50 and 100 it might still be nice to remember it?

i just had this brilliant idea of ... if we save streaks with from and two then we can do things like - auto-fusing streaks e.g. if you have a 50 day streak, it could automatically connect with another 50 day streak even if they're separate by a few days... :) i guess i'm overthinking things a bit... :)

Copy link
Copy Markdown

@klnyzzz33 klnyzzz33 Feb 27, 2026

Choose a reason for hiding this comment

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

I think the longest streak should definitely be saved, yes. It would be nice to show the user on their profile.

For the second part: wouldn't that be a bit misleading because then it's not a streak anymore, strictly speaking? How would we determine the tolerance, would it be always a few days between the streaks? But then I could abuse it, and only log in every 2nd or 3rd day, and still keep my streak going. Or would you only fuse large streaks together, like 50 days or above?


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