386 lines
12 KiB
Python
386 lines
12 KiB
Python
"""
|
|
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
|