336 lines
11 KiB
Python
336 lines
11 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.
|
|
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
|