feat: US-001 - Habits JSON schema and helper functions

This commit is contained in:
Echo
2026-02-10 15:42:51 +00:00
parent a2eae25fe1
commit 8f326b1846
3 changed files with 818 additions and 0 deletions

4
dashboard/habits.json Normal file
View File

@@ -0,0 +1,4 @@
{
"lastUpdated": "",
"habits": []
}

385
dashboard/habits_helpers.py Normal file
View 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

View 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)