Merge feature/habit-tracker into master (squashed): ✨ Habit Tracker Features: - Bead chain visualization (30-day history) - Weekly lives recovery system (+1 life/week) - Lucide icons (zap, shield) replacing emoji - Responsive layout (mobile-optimized) - Navigation links added to all dashboard pages 📚 Knowledge Base: - 40+ trading basics articles with metadata - Daily notes (2026-02-10, 2026-02-11) - Health & insights content - KB index restructuring 🧪 Tests: - Comprehensive test suite (4 test files) - Integration tests for lives recovery - 28/29 tests passing Commits squashed: - feat(habits): bead chain visualization + weekly lives recovery + nav integration - docs(memory): update KB content + daily notes - chore(data): update habits and status data Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
388 lines
13 KiB
Python
388 lines
13 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
|
|
|
|
|
|
def check_and_award_weekly_lives(habit: Dict[str, Any]) -> tuple[int, bool]:
|
|
"""
|
|
Check if habit qualifies for weekly lives recovery and award +1 life if eligible.
|
|
|
|
Awards +1 life if:
|
|
- At least one check-in in the previous week (Monday-Sunday)
|
|
- Not already awarded this week
|
|
|
|
Args:
|
|
habit: Dict containing habit data with completions and lastLivesAward
|
|
|
|
Returns:
|
|
tuple[int, bool]: (new_lives_count, was_awarded)
|
|
"""
|
|
completions = habit.get("completions", [])
|
|
current_lives = habit.get("lives", 3)
|
|
|
|
today = datetime.now().date()
|
|
|
|
# Calculate current week start (Monday 00:00)
|
|
current_week_start = today - timedelta(days=today.weekday())
|
|
|
|
# Check if already awarded this week
|
|
last_lives_award = habit.get("lastLivesAward")
|
|
if last_lives_award:
|
|
last_award_date = datetime.fromisoformat(last_lives_award).date()
|
|
if last_award_date >= current_week_start:
|
|
# Already awarded this week
|
|
return (current_lives, False)
|
|
|
|
# Calculate previous week boundaries
|
|
previous_week_start = current_week_start - timedelta(days=7)
|
|
previous_week_end = current_week_start - timedelta(days=1)
|
|
|
|
# Count check-ins in previous week
|
|
checkins_in_previous_week = 0
|
|
for completion in completions:
|
|
completion_date = datetime.fromisoformat(completion["date"]).date()
|
|
completion_type = completion.get("type", "check")
|
|
|
|
if previous_week_start <= completion_date <= previous_week_end:
|
|
if completion_type == "check":
|
|
checkins_in_previous_week += 1
|
|
|
|
# Award life if at least 1 check-in found
|
|
if checkins_in_previous_week >= 1:
|
|
new_lives = current_lives + 1
|
|
return (new_lives, True)
|
|
|
|
return (current_lives, False)
|