""" Habit Tracker Helper Functions This module provides core helper functions for calculating streaks, checking relevance, and computing stats for habits. """ from datetime import datetime, timedelta from typing import Dict, List, Any, Optional def calculate_streak(habit: Dict[str, Any]) -> int: """ Calculate the current streak for a habit based on its frequency type. Skips maintain the streak (don't break it) but don't count toward the total. Args: habit: Dict containing habit data with frequency, completions, etc. Returns: int: Current streak count (days, weeks, or months depending on frequency) """ frequency_type = habit.get("frequency", {}).get("type", "daily") completions = habit.get("completions", []) if not completions: return 0 # Sort completions by date (newest first) sorted_completions = sorted( [c for c in completions if c.get("date")], key=lambda x: x["date"], reverse=True ) if not sorted_completions: return 0 if frequency_type == "daily": return _calculate_daily_streak(sorted_completions) elif frequency_type == "specific_days": return _calculate_specific_days_streak(habit, sorted_completions) elif frequency_type == "x_per_week": return _calculate_x_per_week_streak(habit, sorted_completions) elif frequency_type == "weekly": return _calculate_weekly_streak(sorted_completions) elif frequency_type == "monthly": return _calculate_monthly_streak(sorted_completions) elif frequency_type == "custom": return _calculate_custom_streak(habit, sorted_completions) return 0 def _calculate_daily_streak(completions: List[Dict[str, Any]]) -> int: """ Calculate streak for daily habits (consecutive days). Skips maintain the streak (don't break it) but don't count toward the total. """ streak = 0 today = datetime.now().date() expected_date = today for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() completion_type = completion.get("type", "check") if completion_date == expected_date: # Only count 'check' completions toward streak total # 'skip' completions maintain the streak but don't extend it if completion_type == "check": streak += 1 expected_date = completion_date - timedelta(days=1) elif completion_date < expected_date: # Gap found, streak breaks break return streak def _calculate_specific_days_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: """Calculate streak for specific days habits (only count relevant days).""" relevant_days = set(habit.get("frequency", {}).get("days", [])) if not relevant_days: return 0 streak = 0 today = datetime.now().date() current_date = today # Find the most recent relevant day while current_date.weekday() not in relevant_days: current_date -= timedelta(days=1) for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() if completion_date == current_date: streak += 1 # Move to previous relevant day current_date -= timedelta(days=1) while current_date.weekday() not in relevant_days: current_date -= timedelta(days=1) elif completion_date < current_date: # Check if we missed a relevant day temp_date = current_date found_gap = False while temp_date > completion_date: if temp_date.weekday() in relevant_days: found_gap = True break temp_date -= timedelta(days=1) if found_gap: break return streak def _calculate_x_per_week_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: """Calculate streak for x_per_week habits (consecutive days with check-ins). For x_per_week habits, streak counts consecutive DAYS with check-ins, not consecutive weeks meeting the target. The weekly target (e.g., 4/week) is a goal, but streak measures the chain of check-in days. """ # Use the same logic as daily habits - count consecutive check-in days return _calculate_daily_streak(completions) def _calculate_weekly_streak(completions: List[Dict[str, Any]]) -> int: """Calculate streak for weekly habits (consecutive days with check-ins). For weekly habits, streak counts consecutive DAYS with check-ins, just like daily habits. The weekly frequency just means you should check in at least once per week. """ return _calculate_daily_streak(completions) def _calculate_monthly_streak(completions: List[Dict[str, Any]]) -> int: """Calculate streak for monthly habits (consecutive days with check-ins). For monthly habits, streak counts consecutive DAYS with check-ins, just like daily habits. The monthly frequency just means you should check in at least once per month. """ return _calculate_daily_streak(completions) def _calculate_custom_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: """Calculate streak for custom interval habits (every X days).""" interval = habit.get("frequency", {}).get("interval", 1) if interval <= 0: return 0 streak = 0 expected_date = datetime.now().date() for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() # Allow completion within the interval window days_diff = (expected_date - completion_date).days if 0 <= days_diff <= interval - 1: streak += 1 expected_date = completion_date - timedelta(days=interval) else: break return streak def should_check_today(habit: Dict[str, Any]) -> bool: """ Check if a habit is relevant for today based on its frequency type. Args: habit: Dict containing habit data with frequency settings Returns: bool: True if the habit should be checked today """ frequency_type = habit.get("frequency", {}).get("type", "daily") today = datetime.now().date() weekday = today.weekday() # 0=Monday, 6=Sunday if frequency_type == "daily": return True elif frequency_type == "specific_days": relevant_days = set(habit.get("frequency", {}).get("days", [])) return weekday in relevant_days elif frequency_type == "x_per_week": # Always relevant for x_per_week (can check any day) return True elif frequency_type == "weekly": # Always relevant (can check any day of the week) return True elif frequency_type == "monthly": # Always relevant (can check any day of the month) return True elif frequency_type == "custom": # Check if enough days have passed since last completion completions = habit.get("completions", []) if not completions: return True interval = habit.get("frequency", {}).get("interval", 1) last_completion = max(completions, key=lambda x: x.get("date", "")) last_date = datetime.fromisoformat(last_completion["date"]).date() days_since = (today - last_date).days return days_since >= interval return False def get_completion_rate(habit: Dict[str, Any], days: int = 30) -> float: """ Calculate the completion rate as a percentage over the last N days. Args: habit: Dict containing habit data days: Number of days to look back (default 30) Returns: float: Completion rate as percentage (0-100) """ frequency_type = habit.get("frequency", {}).get("type", "daily") completions = habit.get("completions", []) today = datetime.now().date() start_date = today - timedelta(days=days - 1) # Count relevant days and checked days relevant_days = 0 checked_dates = set() for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() if start_date <= completion_date <= today: checked_dates.add(completion_date) # Calculate relevant days based on frequency type if frequency_type == "daily": relevant_days = days elif frequency_type == "specific_days": relevant_day_set = set(habit.get("frequency", {}).get("days", [])) current = start_date while current <= today: if current.weekday() in relevant_day_set: relevant_days += 1 current += timedelta(days=1) elif frequency_type == "x_per_week": target_per_week = habit.get("frequency", {}).get("count", 1) num_weeks = days // 7 relevant_days = num_weeks * target_per_week elif frequency_type == "weekly": num_weeks = days // 7 relevant_days = num_weeks elif frequency_type == "monthly": num_months = days // 30 relevant_days = num_months elif frequency_type == "custom": interval = habit.get("frequency", {}).get("interval", 1) relevant_days = days // interval if interval > 0 else 0 if relevant_days == 0: return 0.0 checked_days = len(checked_dates) return (checked_days / relevant_days) * 100 def get_weekly_summary(habit: Dict[str, Any]) -> Dict[str, str]: """ Get a summary of the current week showing status for each day. Args: habit: Dict containing habit data Returns: Dict mapping day names to status: "checked", "skipped", "missed", or "upcoming" """ frequency_type = habit.get("frequency", {}).get("type", "daily") completions = habit.get("completions", []) today = datetime.now().date() # Start of current week (Monday) start_of_week = today - timedelta(days=today.weekday()) # Create completion map completion_map = {} for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() if completion_date >= start_of_week: completion_type = completion.get("type", "check") completion_map[completion_date] = completion_type # Build summary for each day of the week summary = {} day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] for i, day_name in enumerate(day_names): day_date = start_of_week + timedelta(days=i) if day_date > today: summary[day_name] = "upcoming" elif day_date in completion_map: if completion_map[day_date] == "skip": summary[day_name] = "skipped" else: summary[day_name] = "checked" else: # Check if this day was relevant if frequency_type == "specific_days": relevant_days = set(habit.get("frequency", {}).get("days", [])) if day_date.weekday() not in relevant_days: summary[day_name] = "not_relevant" else: summary[day_name] = "missed" else: summary[day_name] = "missed" return summary def check_and_award_weekly_lives(habit: Dict[str, Any]) -> tuple[int, bool]: """ Check if habit qualifies for weekly lives recovery and award +1 life if eligible. Awards +1 life if: - At least one check-in in the previous week (Monday-Sunday) - Not already awarded this week Args: habit: Dict containing habit data with completions and lastLivesAward Returns: tuple[int, bool]: (new_lives_count, was_awarded) """ completions = habit.get("completions", []) current_lives = habit.get("lives", 3) today = datetime.now().date() # Calculate current week start (Monday 00:00) current_week_start = today - timedelta(days=today.weekday()) # Check if already awarded this week last_lives_award = habit.get("lastLivesAward") if last_lives_award: last_award_date = datetime.fromisoformat(last_lives_award).date() if last_award_date >= current_week_start: # Already awarded this week return (current_lives, False) # Calculate previous week boundaries previous_week_start = current_week_start - timedelta(days=7) previous_week_end = current_week_start - timedelta(days=1) # Count check-ins in previous week checkins_in_previous_week = 0 for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() completion_type = completion.get("type", "check") if previous_week_start <= completion_date <= previous_week_end: if completion_type == "check": checkins_in_previous_week += 1 # Award life if at least 1 check-in found if checkins_in_previous_week >= 1: new_lives = current_lives + 1 return (new_lives, True) return (current_lives, False)