feat: US-001 - Habits JSON schema and helper functions
This commit is contained in:
4
dashboard/habits.json
Normal file
4
dashboard/habits.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"lastUpdated": "",
|
||||||
|
"habits": []
|
||||||
|
}
|
||||||
385
dashboard/habits_helpers.py
Normal file
385
dashboard/habits_helpers.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
429
dashboard/tests/test_habits_helpers.py
Normal file
429
dashboard/tests/test_habits_helpers.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Tests for habits_helpers.py
|
||||||
|
|
||||||
|
Tests cover all helper functions for habit tracking including:
|
||||||
|
- calculate_streak for all 6 frequency types
|
||||||
|
- should_check_today for all frequency types
|
||||||
|
- get_completion_rate
|
||||||
|
- get_weekly_summary
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Add parent directory to path to import habits_helpers
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from habits_helpers import (
|
||||||
|
calculate_streak,
|
||||||
|
should_check_today,
|
||||||
|
get_completion_rate,
|
||||||
|
get_weekly_summary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_daily_consecutive():
|
||||||
|
"""Test daily streak with consecutive days."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()},
|
||||||
|
{"date": (today - timedelta(days=1)).isoformat()},
|
||||||
|
{"date": (today - timedelta(days=2)).isoformat()},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert calculate_streak(habit) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_daily_with_gap():
|
||||||
|
"""Test daily streak breaks on gap."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()},
|
||||||
|
{"date": (today - timedelta(days=1)).isoformat()},
|
||||||
|
# Gap here (day 2 missing)
|
||||||
|
{"date": (today - timedelta(days=3)).isoformat()},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert calculate_streak(habit) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_daily_empty():
|
||||||
|
"""Test daily streak with no completions."""
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": []
|
||||||
|
}
|
||||||
|
assert calculate_streak(habit) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_specific_days():
|
||||||
|
"""Test specific_days streak (Mon, Wed, Fri)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# Find the most recent Monday
|
||||||
|
days_since_monday = today.weekday()
|
||||||
|
last_monday = today - timedelta(days=days_since_monday)
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "specific_days",
|
||||||
|
"days": [0, 2, 4] # Mon, Wed, Fri (0=Mon in Python weekday)
|
||||||
|
},
|
||||||
|
"completions": [
|
||||||
|
{"date": last_monday.isoformat()}, # Mon
|
||||||
|
{"date": (last_monday - timedelta(days=2)).isoformat()}, # Fri previous week
|
||||||
|
{"date": (last_monday - timedelta(days=4)).isoformat()}, # Wed previous week
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should count 3 consecutive relevant days
|
||||||
|
streak = calculate_streak(habit)
|
||||||
|
assert streak >= 1 # At least the most recent relevant day
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_x_per_week():
|
||||||
|
"""Test x_per_week streak (3 times per week)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# Find Monday of current week
|
||||||
|
days_since_monday = today.weekday()
|
||||||
|
monday = today - timedelta(days=days_since_monday)
|
||||||
|
|
||||||
|
# Current week: 3 completions (Mon, Tue, Wed)
|
||||||
|
# Previous week: 3 completions (Mon, Tue, Wed)
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "x_per_week",
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"completions": [
|
||||||
|
{"date": monday.isoformat()}, # This week Mon
|
||||||
|
{"date": (monday + timedelta(days=1)).isoformat()}, # This week Tue
|
||||||
|
{"date": (monday + timedelta(days=2)).isoformat()}, # This week Wed
|
||||||
|
# Previous week
|
||||||
|
{"date": (monday - timedelta(days=7)).isoformat()}, # Last week Mon
|
||||||
|
{"date": (monday - timedelta(days=6)).isoformat()}, # Last week Tue
|
||||||
|
{"date": (monday - timedelta(days=5)).isoformat()}, # Last week Wed
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
streak = calculate_streak(habit)
|
||||||
|
assert streak >= 2 # Both weeks meet the target
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_weekly():
|
||||||
|
"""Test weekly streak (at least 1 per week)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "weekly"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()}, # This week
|
||||||
|
{"date": (today - timedelta(days=7)).isoformat()}, # Last week
|
||||||
|
{"date": (today - timedelta(days=14)).isoformat()}, # 2 weeks ago
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
streak = calculate_streak(habit)
|
||||||
|
assert streak >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_monthly():
|
||||||
|
"""Test monthly streak (at least 1 per month)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# This month
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "monthly"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
streak = calculate_streak(habit)
|
||||||
|
assert streak >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_streak_custom_interval():
|
||||||
|
"""Test custom interval streak (every 3 days)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "custom",
|
||||||
|
"interval": 3
|
||||||
|
},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()},
|
||||||
|
{"date": (today - timedelta(days=3)).isoformat()},
|
||||||
|
{"date": (today - timedelta(days=6)).isoformat()},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
streak = calculate_streak(habit)
|
||||||
|
assert streak == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_daily():
|
||||||
|
"""Test should_check_today for daily habit."""
|
||||||
|
habit = {"frequency": {"type": "daily"}}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_specific_days():
|
||||||
|
"""Test should_check_today for specific_days habit."""
|
||||||
|
today_weekday = datetime.now().date().weekday()
|
||||||
|
|
||||||
|
# Habit relevant today
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "specific_days",
|
||||||
|
"days": [today_weekday]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
# Habit not relevant today
|
||||||
|
other_day = (today_weekday + 1) % 7
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "specific_days",
|
||||||
|
"days": [other_day]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert should_check_today(habit) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_x_per_week():
|
||||||
|
"""Test should_check_today for x_per_week habit."""
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "x_per_week",
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_weekly():
|
||||||
|
"""Test should_check_today for weekly habit."""
|
||||||
|
habit = {"frequency": {"type": "weekly"}}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_monthly():
|
||||||
|
"""Test should_check_today for monthly habit."""
|
||||||
|
habit = {"frequency": {"type": "monthly"}}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_custom_ready():
|
||||||
|
"""Test should_check_today for custom interval when ready."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "custom",
|
||||||
|
"interval": 3
|
||||||
|
},
|
||||||
|
"completions": [
|
||||||
|
{"date": (today - timedelta(days=3)).isoformat()}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert should_check_today(habit) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_check_today_custom_not_ready():
|
||||||
|
"""Test should_check_today for custom interval when not ready."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "custom",
|
||||||
|
"interval": 3
|
||||||
|
},
|
||||||
|
"completions": [
|
||||||
|
{"date": (today - timedelta(days=1)).isoformat()}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert should_check_today(habit) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completion_rate_daily_perfect():
|
||||||
|
"""Test completion rate for daily habit with 100%."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
completions = []
|
||||||
|
for i in range(30):
|
||||||
|
completions.append({"date": (today - timedelta(days=i)).isoformat()})
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": completions
|
||||||
|
}
|
||||||
|
|
||||||
|
rate = get_completion_rate(habit, days=30)
|
||||||
|
assert rate == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completion_rate_daily_half():
|
||||||
|
"""Test completion rate for daily habit with 50%."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
completions = []
|
||||||
|
for i in range(0, 30, 2): # Every other day
|
||||||
|
completions.append({"date": (today - timedelta(days=i)).isoformat()})
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": completions
|
||||||
|
}
|
||||||
|
|
||||||
|
rate = get_completion_rate(habit, days=30)
|
||||||
|
assert 45 <= rate <= 55 # Around 50%
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completion_rate_specific_days():
|
||||||
|
"""Test completion rate for specific_days habit."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
today_weekday = today.weekday()
|
||||||
|
|
||||||
|
# Create habit for Mon, Wed, Fri
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "specific_days",
|
||||||
|
"days": [0, 2, 4]
|
||||||
|
},
|
||||||
|
"completions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add completions for all relevant days in last 30 days
|
||||||
|
for i in range(30):
|
||||||
|
check_date = today - timedelta(days=i)
|
||||||
|
if check_date.weekday() in [0, 2, 4]:
|
||||||
|
habit["completions"].append({"date": check_date.isoformat()})
|
||||||
|
|
||||||
|
rate = get_completion_rate(habit, days=30)
|
||||||
|
assert rate == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completion_rate_empty():
|
||||||
|
"""Test completion rate with no completions."""
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
rate = get_completion_rate(habit, days=30)
|
||||||
|
assert rate == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weekly_summary():
|
||||||
|
"""Test weekly summary returns correct structure."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat()},
|
||||||
|
{"date": (today - timedelta(days=1)).isoformat()},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = get_weekly_summary(habit)
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
assert isinstance(summary, dict)
|
||||||
|
assert "Monday" in summary
|
||||||
|
assert "Tuesday" in summary
|
||||||
|
assert "Wednesday" in summary
|
||||||
|
assert "Thursday" in summary
|
||||||
|
assert "Friday" in summary
|
||||||
|
assert "Saturday" in summary
|
||||||
|
assert "Sunday" in summary
|
||||||
|
|
||||||
|
# Check values are valid
|
||||||
|
valid_statuses = ["checked", "skipped", "missed", "upcoming", "not_relevant"]
|
||||||
|
for day, status in summary.items():
|
||||||
|
assert status in valid_statuses
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weekly_summary_with_skip():
|
||||||
|
"""Test weekly summary handles skipped days."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
habit = {
|
||||||
|
"frequency": {"type": "daily"},
|
||||||
|
"completions": [
|
||||||
|
{"date": today.isoformat(), "type": "check"},
|
||||||
|
{"date": (today - timedelta(days=1)).isoformat(), "type": "skip"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = get_weekly_summary(habit)
|
||||||
|
|
||||||
|
# Find today's day name
|
||||||
|
day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
today_name = day_names[today.weekday()]
|
||||||
|
yesterday_name = day_names[(today.weekday() - 1) % 7]
|
||||||
|
|
||||||
|
assert summary[today_name] == "checked"
|
||||||
|
assert summary[yesterday_name] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weekly_summary_specific_days():
|
||||||
|
"""Test weekly summary marks non-relevant days correctly."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
today_weekday = today.weekday()
|
||||||
|
|
||||||
|
# Habit only for Monday (0)
|
||||||
|
habit = {
|
||||||
|
"frequency": {
|
||||||
|
"type": "specific_days",
|
||||||
|
"days": [0]
|
||||||
|
},
|
||||||
|
"completions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = get_weekly_summary(habit)
|
||||||
|
|
||||||
|
# All days except Monday should be not_relevant or upcoming
|
||||||
|
for day_name, status in summary.items():
|
||||||
|
if day_name == "Monday":
|
||||||
|
continue # Monday can be any status
|
||||||
|
if status not in ["upcoming", "not_relevant"]:
|
||||||
|
# Day should be not_relevant if it's in the past
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run all tests
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
test_functions = [
|
||||||
|
obj for name, obj in inspect.getmembers(sys.modules[__name__])
|
||||||
|
if inspect.isfunction(obj) and name.startswith("test_")
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for test_func in test_functions:
|
||||||
|
try:
|
||||||
|
test_func()
|
||||||
|
print(f"✓ {test_func.__name__}")
|
||||||
|
passed += 1
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ {test_func.__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test_func.__name__}: {type(e).__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{passed} passed, {failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
Reference in New Issue
Block a user