""" 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. 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).""" streak = 0 today = datetime.now().date() expected_date = today for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() if completion_date == expected_date: 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 weeks meeting target).""" target_count = habit.get("frequency", {}).get("count", 1) streak = 0 today = datetime.now().date() # Group completions by week week_counts = {} for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() # Get ISO week (year, week_number) week_key = completion_date.isocalendar()[:2] week_counts[week_key] = week_counts.get(week_key, 0) + 1 # Start from current week and count backwards current_week = today.isocalendar()[:2] while current_week in week_counts: if week_counts[current_week] >= target_count: streak += 1 # Move to previous week year, week = current_week if week == 1: year -= 1 week = 52 else: week -= 1 current_week = (year, week) else: break return streak def _calculate_weekly_streak(completions: List[Dict[str, Any]]) -> int: """Calculate streak for weekly habits (consecutive weeks with at least one check).""" today = datetime.now().date() # Group completions by week weeks_with_checks = set() for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() week_key = completion_date.isocalendar()[:2] weeks_with_checks.add(week_key) streak = 0 current_week = today.isocalendar()[:2] while current_week in weeks_with_checks: streak += 1 year, week = current_week if week == 1: year -= 1 week = 52 else: week -= 1 current_week = (year, week) return streak def _calculate_monthly_streak(completions: List[Dict[str, Any]]) -> int: """Calculate streak for monthly habits (consecutive months with at least one check).""" today = datetime.now().date() # Group completions by month months_with_checks = set() for completion in completions: completion_date = datetime.fromisoformat(completion["date"]).date() month_key = (completion_date.year, completion_date.month) months_with_checks.add(month_key) streak = 0 current_month = (today.year, today.month) while current_month in months_with_checks: streak += 1 year, month = current_month if month == 1: year -= 1 month = 12 else: month -= 1 current_month = (year, month) return streak 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