feat: 4.0 - Backend API - Streak calculation utility
This commit is contained in:
101
dashboard/api.py
101
dashboard/api.py
@@ -35,6 +35,107 @@ GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.romfast.ro')
|
||||
GITEA_ORG = os.environ.get('GITEA_ORG', 'romfast')
|
||||
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '')
|
||||
|
||||
|
||||
def calculate_streak(completions, frequency):
|
||||
"""
|
||||
Calculate the current streak for a habit based on completions array.
|
||||
|
||||
Args:
|
||||
completions: List of ISO timestamp strings representing completion dates
|
||||
frequency: 'daily' or 'weekly'
|
||||
|
||||
Returns:
|
||||
int: The current streak count (days for daily, weeks for weekly)
|
||||
|
||||
Rules:
|
||||
- Counts consecutive periods from most recent completion backwards
|
||||
- Daily: counts consecutive days without gaps
|
||||
- Weekly: counts consecutive 7-day periods
|
||||
- Returns 0 for no completions
|
||||
- Returns 0 if streak is broken (gap detected)
|
||||
- Today's completion counts even if previous days were missed
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# No completions = no streak
|
||||
if not completions:
|
||||
return 0
|
||||
|
||||
# Parse all completion dates and sort descending (most recent first)
|
||||
try:
|
||||
completion_dates = []
|
||||
for comp in completions:
|
||||
dt = datetime.fromisoformat(comp.replace('Z', '+00:00'))
|
||||
# Convert to date only (ignore time)
|
||||
completion_dates.append(dt.date())
|
||||
|
||||
completion_dates = sorted(set(completion_dates), reverse=True)
|
||||
except (ValueError, AttributeError):
|
||||
return 0
|
||||
|
||||
if not completion_dates:
|
||||
return 0
|
||||
|
||||
# Get today's date
|
||||
today = datetime.now().date()
|
||||
|
||||
if frequency == 'daily':
|
||||
# For daily habits, count consecutive days
|
||||
streak = 0
|
||||
expected_date = completion_dates[0]
|
||||
|
||||
# If most recent completion is today or yesterday, start counting
|
||||
if expected_date < today - timedelta(days=1):
|
||||
# Streak is broken (last completion was more than 1 day ago)
|
||||
return 0
|
||||
|
||||
for completion in completion_dates:
|
||||
if completion == expected_date:
|
||||
streak += 1
|
||||
expected_date -= timedelta(days=1)
|
||||
elif completion < expected_date:
|
||||
# Gap found, streak is broken
|
||||
break
|
||||
|
||||
return streak
|
||||
|
||||
elif frequency == 'weekly':
|
||||
# For weekly habits, count consecutive weeks (7-day periods)
|
||||
streak = 0
|
||||
|
||||
# Most recent completion
|
||||
most_recent = completion_dates[0]
|
||||
|
||||
# Check if most recent completion is within current week
|
||||
days_since = (today - most_recent).days
|
||||
if days_since > 6:
|
||||
# Last completion was more than a week ago, streak is broken
|
||||
return 0
|
||||
|
||||
# Start counting from the week of the most recent completion
|
||||
current_week_start = most_recent - timedelta(days=most_recent.weekday())
|
||||
|
||||
for i in range(len(completion_dates)):
|
||||
week_start = current_week_start - timedelta(days=i * 7)
|
||||
week_end = week_start + timedelta(days=6)
|
||||
|
||||
# Check if there's a completion in this week
|
||||
has_completion = any(
|
||||
week_start <= comp <= week_end
|
||||
for comp in completion_dates
|
||||
)
|
||||
|
||||
if has_completion:
|
||||
streak += 1
|
||||
else:
|
||||
# No completion in this week, streak is broken
|
||||
break
|
||||
|
||||
return streak
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
179
dashboard/test_habits_streak.py
Normal file
179
dashboard/test_habits_streak.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for streak calculation utility.
|
||||
Story 4.0: Backend API - Streak calculation utility
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Add dashboard to path to import api module
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from api import calculate_streak
|
||||
|
||||
|
||||
def test_no_completions():
|
||||
"""Returns 0 for no completions"""
|
||||
assert calculate_streak([], 'daily') == 0
|
||||
assert calculate_streak([], 'weekly') == 0
|
||||
print("✓ No completions returns 0")
|
||||
|
||||
|
||||
def test_daily_single_completion_today():
|
||||
"""Single completion today counts as streak of 1"""
|
||||
today = datetime.now().isoformat()
|
||||
assert calculate_streak([today], 'daily') == 1
|
||||
print("✓ Daily: single completion today = streak 1")
|
||||
|
||||
|
||||
def test_daily_single_completion_yesterday():
|
||||
"""Single completion yesterday counts as streak of 1"""
|
||||
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
|
||||
assert calculate_streak([yesterday], 'daily') == 1
|
||||
print("✓ Daily: single completion yesterday = streak 1")
|
||||
|
||||
|
||||
def test_daily_consecutive_days():
|
||||
"""Multiple consecutive days count correctly"""
|
||||
completions = [
|
||||
(datetime.now() - timedelta(days=i)).isoformat()
|
||||
for i in range(5) # Today, yesterday, 2 days ago, 3 days ago, 4 days ago
|
||||
]
|
||||
assert calculate_streak(completions, 'daily') == 5
|
||||
print("✓ Daily: 5 consecutive days = streak 5")
|
||||
|
||||
|
||||
def test_daily_broken_streak():
|
||||
"""Gap in daily completions breaks streak"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
(today - timedelta(days=1)).isoformat(),
|
||||
# Gap here (day 2 missing)
|
||||
(today - timedelta(days=3)).isoformat(),
|
||||
(today - timedelta(days=4)).isoformat(),
|
||||
]
|
||||
# Should count only today and yesterday before the gap
|
||||
assert calculate_streak(completions, 'daily') == 2
|
||||
print("✓ Daily: gap breaks streak (counts only before gap)")
|
||||
|
||||
|
||||
def test_daily_old_completion():
|
||||
"""Completion more than 1 day ago returns 0"""
|
||||
two_days_ago = (datetime.now() - timedelta(days=2)).isoformat()
|
||||
assert calculate_streak([two_days_ago], 'daily') == 0
|
||||
print("✓ Daily: completion >1 day ago = streak 0")
|
||||
|
||||
|
||||
def test_weekly_single_completion_this_week():
|
||||
"""Single completion this week counts as streak of 1"""
|
||||
today = datetime.now().isoformat()
|
||||
assert calculate_streak([today], 'weekly') == 1
|
||||
print("✓ Weekly: single completion this week = streak 1")
|
||||
|
||||
|
||||
def test_weekly_consecutive_weeks():
|
||||
"""Multiple consecutive weeks count correctly"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
(today - timedelta(days=7)).isoformat(),
|
||||
(today - timedelta(days=14)).isoformat(),
|
||||
(today - timedelta(days=21)).isoformat(),
|
||||
]
|
||||
assert calculate_streak(completions, 'weekly') == 4
|
||||
print("✓ Weekly: 4 consecutive weeks = streak 4")
|
||||
|
||||
|
||||
def test_weekly_broken_streak():
|
||||
"""Missing week breaks streak"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
(today - timedelta(days=7)).isoformat(),
|
||||
# Gap here (week 2 missing)
|
||||
(today - timedelta(days=21)).isoformat(),
|
||||
]
|
||||
# Should count only current week and last week before the gap
|
||||
assert calculate_streak(completions, 'weekly') == 2
|
||||
print("✓ Weekly: missing week breaks streak")
|
||||
|
||||
|
||||
def test_weekly_old_completion():
|
||||
"""Completion more than 7 days ago returns 0"""
|
||||
eight_days_ago = (datetime.now() - timedelta(days=8)).isoformat()
|
||||
assert calculate_streak([eight_days_ago], 'weekly') == 0
|
||||
print("✓ Weekly: completion >7 days ago = streak 0")
|
||||
|
||||
|
||||
def test_multiple_completions_same_day():
|
||||
"""Multiple completions on same day count as one"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
(today - timedelta(hours=2)).isoformat(), # Same day, different time
|
||||
(today - timedelta(days=1)).isoformat(),
|
||||
]
|
||||
assert calculate_streak(completions, 'daily') == 2
|
||||
print("✓ Daily: multiple completions same day = 1 day")
|
||||
|
||||
|
||||
def test_todays_completion_counts():
|
||||
"""Today's completion counts even if yesterday was missed"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
# Yesterday missing
|
||||
(today - timedelta(days=2)).isoformat(),
|
||||
]
|
||||
# Should count only today (yesterday breaks the streak to previous days)
|
||||
assert calculate_streak(completions, 'daily') == 1
|
||||
print("✓ Daily: today counts even if yesterday missed")
|
||||
|
||||
|
||||
def test_invalid_date_format():
|
||||
"""Invalid date format returns 0"""
|
||||
assert calculate_streak(['not-a-date'], 'daily') == 0
|
||||
assert calculate_streak(['2026-13-45'], 'daily') == 0
|
||||
print("✓ Invalid date format returns 0")
|
||||
|
||||
|
||||
def test_weekly_multiple_in_same_week():
|
||||
"""Multiple completions in same week count as one week"""
|
||||
today = datetime.now()
|
||||
completions = [
|
||||
today.isoformat(),
|
||||
(today - timedelta(days=2)).isoformat(), # Same week
|
||||
(today - timedelta(days=4)).isoformat(), # Same week
|
||||
(today - timedelta(days=7)).isoformat(), # Previous week
|
||||
]
|
||||
assert calculate_streak(completions, 'weekly') == 2
|
||||
print("✓ Weekly: multiple in same week = 1 week")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all streak calculation tests"""
|
||||
print("\n=== Testing Streak Calculation ===\n")
|
||||
|
||||
test_no_completions()
|
||||
test_daily_single_completion_today()
|
||||
test_daily_single_completion_yesterday()
|
||||
test_daily_consecutive_days()
|
||||
test_daily_broken_streak()
|
||||
test_daily_old_completion()
|
||||
test_weekly_single_completion_this_week()
|
||||
test_weekly_consecutive_weeks()
|
||||
test_weekly_broken_streak()
|
||||
test_weekly_old_completion()
|
||||
test_multiple_completions_same_day()
|
||||
test_todays_completion_counts()
|
||||
test_invalid_date_format()
|
||||
test_weekly_multiple_in_same_week()
|
||||
|
||||
print("\n✓ All streak calculation tests passed!\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_all_tests()
|
||||
Reference in New Issue
Block a user