From 8f326b1846836902ceedc0d3f7054527e98c653d Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 15:42:51 +0000 Subject: [PATCH] feat: US-001 - Habits JSON schema and helper functions --- dashboard/habits.json | 4 + dashboard/habits_helpers.py | 385 ++++++++++++++++++++++ dashboard/tests/test_habits_helpers.py | 429 +++++++++++++++++++++++++ 3 files changed, 818 insertions(+) create mode 100644 dashboard/habits.json create mode 100644 dashboard/habits_helpers.py create mode 100644 dashboard/tests/test_habits_helpers.py diff --git a/dashboard/habits.json b/dashboard/habits.json new file mode 100644 index 0000000..fad8cb8 --- /dev/null +++ b/dashboard/habits.json @@ -0,0 +1,4 @@ +{ + "lastUpdated": "", + "habits": [] +} diff --git a/dashboard/habits_helpers.py b/dashboard/habits_helpers.py new file mode 100644 index 0000000..9d1d031 --- /dev/null +++ b/dashboard/habits_helpers.py @@ -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 diff --git a/dashboard/tests/test_habits_helpers.py b/dashboard/tests/test_habits_helpers.py new file mode 100644 index 0000000..a66b823 --- /dev/null +++ b/dashboard/tests/test_habits_helpers.py @@ -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)