diff --git a/dashboard/api.py b/dashboard/api.py index 97c3955..91a465a 100644 --- a/dashboard/api.py +++ b/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): diff --git a/dashboard/test_habits_streak.py b/dashboard/test_habits_streak.py new file mode 100644 index 0000000..209009b --- /dev/null +++ b/dashboard/test_habits_streak.py @@ -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()