feat: habit tracker with gamification + KB updates

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>
This commit is contained in:
Echo
2026-02-11 11:03:54 +00:00
parent a2eae25fe1
commit 719cd5f743
97 changed files with 19631 additions and 1233 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,573 @@
"""
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,
check_and_award_weekly_lives
)
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
def test_check_and_award_weekly_lives_awards_life_with_checkin():
"""Test that +1 life is awarded if there was ≥1 check-in in previous week."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Add check-in in previous week (Wednesday)
habit = {
"lives": 2,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == True
assert new_lives == 3
def test_check_and_award_weekly_lives_no_award_without_checkin():
"""Test that no life is awarded if there were no check-ins in previous week."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
# Add check-in in current week only
habit = {
"lives": 2,
"completions": [
{"date": (current_week_start + timedelta(days=1)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == False
assert new_lives == 2
def test_check_and_award_weekly_lives_no_duplicate_award():
"""Test that life is not awarded twice in the same week."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Add check-in in previous week and mark as already awarded this week
habit = {
"lives": 3,
"lastLivesAward": current_week_start.isoformat(),
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == False
assert new_lives == 3
def test_check_and_award_weekly_lives_skip_doesnt_count():
"""Test that skips don't count toward weekly recovery."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Add only skips in previous week, no check-ins
habit = {
"lives": 1,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "skip"},
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "skip"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == False
assert new_lives == 1
def test_check_and_award_weekly_lives_multiple_checkins():
"""Test that award works with multiple check-ins in previous week."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Add multiple check-ins in previous week
habit = {
"lives": 2,
"completions": [
{"date": (previous_week_start + timedelta(days=1)).isoformat(), "type": "check"},
{"date": (previous_week_start + timedelta(days=3)).isoformat(), "type": "check"},
{"date": (previous_week_start + timedelta(days=5)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == True
assert new_lives == 3
def test_check_and_award_weekly_lives_no_cap():
"""Test that lives can accumulate beyond 3."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Habit with 5 lives
habit = {
"lives": 5,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == True
assert new_lives == 6
def test_check_and_award_weekly_lives_missing_last_award_field():
"""Test backward compatibility when lastLivesAward field is missing."""
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Habit without lastLivesAward field (backward compatible)
habit = {
"lives": 2,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == True
assert new_lives == 3
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)

View File

@@ -0,0 +1,555 @@
#!/usr/bin/env python3
"""
Integration tests for Habits feature - End-to-end flows
Tests complete workflows involving multiple API calls and state transitions.
"""
import json
import os
import sys
import tempfile
import shutil
from datetime import datetime, timedelta
from http.server import HTTPServer
from threading import Thread
import urllib.request
import urllib.error
# Add parent directory to path to import api module
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from api import TaskBoardHandler
import habits_helpers
# Test helpers
def setup_test_env():
"""Create temporary environment for testing"""
from pathlib import Path
temp_dir = tempfile.mkdtemp()
habits_file = Path(temp_dir) / 'habits.json'
# Initialize empty habits file
with open(habits_file, 'w') as f:
json.dump({'lastUpdated': datetime.now().isoformat(), 'habits': []}, f)
# Override HABITS_FILE constant
import api
api.HABITS_FILE = habits_file
return temp_dir
def teardown_test_env(temp_dir):
"""Clean up temporary environment"""
shutil.rmtree(temp_dir)
def start_test_server():
"""Start HTTP server on random port for testing"""
server = HTTPServer(('localhost', 0), TaskBoardHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def http_request(url, method='GET', data=None):
"""Make HTTP request and return response data"""
headers = {'Content-Type': 'application/json'}
if data:
data = json.dumps(data).encode('utf-8')
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as response:
body = response.read().decode('utf-8')
return json.loads(body) if body else None
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
try:
return {'error': json.loads(error_body), 'status': e.code}
except:
return {'error': error_body, 'status': e.code}
# Integration Tests
def test_01_create_and_checkin_increments_streak():
"""Integration test: create habit → check-in → verify streak is 1"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create daily habit
habit_data = {
'name': 'Morning meditation',
'category': 'health',
'color': '#10B981',
'icon': 'brain',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
if 'error' in result:
print(f"Error creating habit: {result}")
assert 'id' in result, f"Should return created habit with ID, got: {result}"
habit_id = result['id']
# Check in today
checkin_result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST')
# Verify streak incremented to 1
assert checkin_result['streak']['current'] == 1, "Streak should be 1 after first check-in"
assert checkin_result['streak']['best'] == 1, "Best streak should be 1 after first check-in"
assert checkin_result['streak']['lastCheckIn'] == datetime.now().date().isoformat(), "Last check-in should be today"
print("✓ Test 1: Create + check-in → streak is 1")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_02_seven_consecutive_checkins_restore_life():
"""Integration test: 7 consecutive check-ins → life restored (if below 3)"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create daily habit
habit_data = {
'name': 'Daily exercise',
'category': 'health',
'color': '#EF4444',
'icon': 'dumbbell',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Manually set lives to 1 (instead of using skip API which would add completions)
import api
with open(api.HABITS_FILE, 'r') as f:
data = json.load(f)
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
habit_obj['lives'] = 1 # Directly set to 1 (simulating 2 skips used)
# Add 7 consecutive check-in completions for the past 7 days
for i in range(7):
check_date = (datetime.now() - timedelta(days=6-i)).date().isoformat()
habit_obj['completions'].append({
'date': check_date,
'type': 'check'
})
# Recalculate streak and check for life restore
habit_obj['streak'] = {
'current': habits_helpers.calculate_streak(habit_obj),
'best': max(habit_obj['streak']['best'], habits_helpers.calculate_streak(habit_obj)),
'lastCheckIn': datetime.now().date().isoformat()
}
# Check life restore logic: last 7 completions all 'check' type
last_7 = habit_obj['completions'][-7:]
if len(last_7) == 7 and all(c.get('type') == 'check' for c in last_7):
if habit_obj['lives'] < 3:
habit_obj['lives'] += 1
data['lastUpdated'] = datetime.now().isoformat()
with open(api.HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Get updated habit
habits = http_request(f"{base_url}/api/habits")
habit = next(h for h in habits if h['id'] == habit_id)
# Verify life restored
assert habit['lives'] == 2, f"Should have 2 lives after 7 consecutive check-ins (was {habit['lives']})"
assert habit['current_streak'] == 7, "Should have streak of 7"
print("✓ Test 2: 7 consecutive check-ins → life restored")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_03_skip_with_life_maintains_streak():
"""Integration test: skip with life → lives decremented, streak unchanged"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create daily habit
habit_data = {
'name': 'Read book',
'category': 'growth',
'color': '#3B82F6',
'icon': 'book',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Check in yesterday (to build a streak)
import api
with open(api.HABITS_FILE, 'r') as f:
data = json.load(f)
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
yesterday = (datetime.now() - timedelta(days=1)).date().isoformat()
habit_obj['completions'].append({
'date': yesterday,
'type': 'check'
})
habit_obj['streak'] = {
'current': 1,
'best': 1,
'lastCheckIn': yesterday
}
data['lastUpdated'] = datetime.now().isoformat()
with open(api.HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Skip today
skip_result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
# Verify lives decremented and streak maintained
assert skip_result['lives'] == 2, "Lives should be 2 after skip"
# Get fresh habit data to check streak
habits = http_request(f"{base_url}/api/habits")
habit = next(h for h in habits if h['id'] == habit_id)
# Streak should still be 1 (skip doesn't break it)
assert habit['current_streak'] == 1, "Streak should be maintained after skip"
print("✓ Test 3: Skip with life → lives decremented, streak unchanged")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_04_skip_with_zero_lives_returns_400():
"""Integration test: skip with 0 lives → returns 400 error"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create daily habit
habit_data = {
'name': 'Yoga practice',
'category': 'health',
'color': '#8B5CF6',
'icon': 'heart',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Use all 3 lives
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
# Attempt to skip with 0 lives
result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
# Verify 400 error
assert result['status'] == 400, "Should return 400 status"
assert 'error' in result, "Should return error message"
print("✓ Test 4: Skip with 0 lives → returns 400 error")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_05_edit_frequency_changes_should_check_today():
"""Integration test: edit frequency → should_check_today logic changes"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create daily habit
habit_data = {
'name': 'Code review',
'category': 'work',
'color': '#F59E0B',
'icon': 'code',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Verify should_check_today is True for daily habit
habits = http_request(f"{base_url}/api/habits")
habit = next(h for h in habits if h['id'] == habit_id)
assert habit['should_check_today'] == True, "Daily habit should be checkable today"
# Edit to specific_days (only Monday and Wednesday)
update_data = {
'name': 'Code review',
'category': 'work',
'color': '#F59E0B',
'icon': 'code',
'priority': 50,
'frequency': {
'type': 'specific_days',
'days': ['monday', 'wednesday']
}
}
http_request(f"{base_url}/api/habits/{habit_id}", method='PUT', data=update_data)
# Get updated habit
habits = http_request(f"{base_url}/api/habits")
habit = next(h for h in habits if h['id'] == habit_id)
# Verify should_check_today reflects new frequency
today_name = datetime.now().strftime('%A').lower()
expected = today_name in ['monday', 'wednesday']
assert habit['should_check_today'] == expected, f"Should check today should be {expected} for {today_name}"
print(f"✓ Test 5: Edit frequency → should_check_today is {expected} for {today_name}")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_06_delete_removes_habit_from_storage():
"""Integration test: delete → habit removed from storage"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create habit
habit_data = {
'name': 'Guitar practice',
'category': 'personal',
'color': '#EC4899',
'icon': 'music',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Verify habit exists
habits = http_request(f"{base_url}/api/habits")
assert len(habits) == 1, "Should have 1 habit"
assert habits[0]['id'] == habit_id, "Should be the created habit"
# Delete habit
http_request(f"{base_url}/api/habits/{habit_id}", method='DELETE')
# Verify habit removed
habits = http_request(f"{base_url}/api/habits")
assert len(habits) == 0, "Should have 0 habits after delete"
# Verify not in storage file
import api
with open(api.HABITS_FILE, 'r') as f:
data = json.load(f)
assert len(data['habits']) == 0, "Storage file should have 0 habits"
print("✓ Test 6: Delete → habit removed from storage")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_07_checkin_on_wrong_day_for_specific_days_returns_400():
"""Integration test: check-in on wrong day for specific_days → returns 400"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Get today's day name
today_name = datetime.now().strftime('%A').lower()
# Create habit for different days (not today)
if today_name == 'monday':
allowed_days = ['tuesday', 'wednesday']
elif today_name == 'tuesday':
allowed_days = ['monday', 'wednesday']
else:
allowed_days = ['monday', 'tuesday']
habit_data = {
'name': 'Gym workout',
'category': 'health',
'color': '#EF4444',
'icon': 'dumbbell',
'priority': 50,
'frequency': {
'type': 'specific_days',
'days': allowed_days
}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Attempt to check in today (wrong day)
result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST')
# Verify 400 error
assert result['status'] == 400, "Should return 400 status"
assert 'error' in result, "Should return error message"
print(f"✓ Test 7: Check-in on {today_name} (not in {allowed_days}) → returns 400")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_08_get_response_includes_all_stats():
"""Integration test: GET response includes stats (streak, completion_rate, weekly_summary)"""
temp_dir = setup_test_env()
server = start_test_server()
base_url = f"http://localhost:{server.server_port}"
try:
# Create habit with some completions
habit_data = {
'name': 'Meditation',
'category': 'health',
'color': '#10B981',
'icon': 'brain',
'priority': 50,
'frequency': {'type': 'daily'}
}
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
habit_id = result['id']
# Add some completions
import api
with open(api.HABITS_FILE, 'r') as f:
data = json.load(f)
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
# Add completions for last 3 days
for i in range(3):
check_date = (datetime.now() - timedelta(days=2-i)).date().isoformat()
habit_obj['completions'].append({
'date': check_date,
'type': 'check'
})
habit_obj['streak'] = {
'current': 3,
'best': 3,
'lastCheckIn': datetime.now().date().isoformat()
}
data['lastUpdated'] = datetime.now().isoformat()
with open(api.HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Get habits
habits = http_request(f"{base_url}/api/habits")
habit = habits[0]
# Verify all enriched stats are present
assert 'current_streak' in habit, "Should include current_streak"
assert 'best_streak' in habit, "Should include best_streak"
assert 'completion_rate_30d' in habit, "Should include completion_rate_30d"
assert 'weekly_summary' in habit, "Should include weekly_summary"
assert 'should_check_today' in habit, "Should include should_check_today"
# Verify streak values
assert habit['current_streak'] == 3, "Current streak should be 3"
assert habit['best_streak'] == 3, "Best streak should be 3"
# Verify weekly_summary structure
assert isinstance(habit['weekly_summary'], dict), "Weekly summary should be a dict"
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
for day in days:
assert day in habit['weekly_summary'], f"Weekly summary should include {day}"
print("✓ Test 8: GET response includes all stats (streak, completion_rate, weekly_summary)")
finally:
server.shutdown()
teardown_test_env(temp_dir)
def test_09_typecheck_passes():
"""Integration test: Typecheck passes"""
result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/api.py')
assert result == 0, "Typecheck should pass for api.py"
result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/habits_helpers.py')
assert result == 0, "Typecheck should pass for habits_helpers.py"
print("✓ Test 9: Typecheck passes")
# Run all tests
if __name__ == '__main__':
tests = [
test_01_create_and_checkin_increments_streak,
test_02_seven_consecutive_checkins_restore_life,
test_03_skip_with_life_maintains_streak,
test_04_skip_with_zero_lives_returns_400,
test_05_edit_frequency_changes_should_check_today,
test_06_delete_removes_habit_from_storage,
test_07_checkin_on_wrong_day_for_specific_days_returns_400,
test_08_get_response_includes_all_stats,
test_09_typecheck_passes,
]
passed = 0
failed = 0
print("Running integration tests...\n")
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"{test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"{test.__name__}: Unexpected error: {e}")
import traceback
traceback.print_exc()
failed += 1
print(f"\n{'='*50}")
print(f"Integration Tests: {passed} passed, {failed} failed")
print(f"{'='*50}")
sys.exit(0 if failed == 0 else 1)

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Integration test for weekly lives recovery feature.
Tests the full flow:
1. Habit has check-ins in previous week
2. Check-in today triggers weekly lives recovery
3. Response includes livesAwarded flag
4. Lives count increases
5. Duplicate awards are prevented
"""
import sys
import os
from datetime import datetime, timedelta
import json
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from habits_helpers import check_and_award_weekly_lives
def test_integration_weekly_lives_award():
"""Test complete weekly lives recovery flow."""
print("\n=== Testing Weekly Lives Recovery Integration ===\n")
today = datetime.now().date()
current_week_start = today - timedelta(days=today.weekday())
previous_week_start = current_week_start - timedelta(days=7)
# Scenario 1: New habit with check-ins in previous week
print("Scenario 1: First award of the week")
habit = {
"id": "test-habit-1",
"name": "Test Habit",
"lives": 2,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"},
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "check"},
]
}
new_lives, was_awarded = check_and_award_weekly_lives(habit)
assert was_awarded == True, "Expected life to be awarded"
assert new_lives == 3, f"Expected 3 lives, got {new_lives}"
print(f"✓ Lives awarded: {habit['lives']}{new_lives}")
print(f"✓ Award flag: {was_awarded}")
# Scenario 2: Already awarded this week
print("\nScenario 2: Prevent duplicate award")
habit['lives'] = new_lives
habit['lastLivesAward'] = current_week_start.isoformat()
new_lives2, was_awarded2 = check_and_award_weekly_lives(habit)
assert was_awarded2 == False, "Expected no duplicate award"
assert new_lives2 == 3, f"Lives should remain at 3, got {new_lives2}"
print(f"✓ No duplicate award: lives remain at {new_lives2}")
# Scenario 3: Only skips in previous week
print("\nScenario 3: Skips don't qualify for recovery")
habit_with_skips = {
"id": "test-habit-2",
"name": "Habit with Skips",
"lives": 1,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "skip"},
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "skip"},
]
}
new_lives3, was_awarded3 = check_and_award_weekly_lives(habit_with_skips)
assert was_awarded3 == False, "Skips shouldn't trigger award"
assert new_lives3 == 1, f"Lives should remain at 1, got {new_lives3}"
print(f"✓ Skips don't count: lives remain at {new_lives3}")
# Scenario 4: No cap on lives (can go beyond 3)
print("\nScenario 4: Lives can exceed 3")
habit_many_lives = {
"id": "test-habit-3",
"name": "Habit with Many Lives",
"lives": 5,
"completions": [
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"},
]
}
new_lives4, was_awarded4 = check_and_award_weekly_lives(habit_many_lives)
assert was_awarded4 == True, "Expected life to be awarded"
assert new_lives4 == 6, f"Expected 6 lives, got {new_lives4}"
print(f"✓ No cap: lives increased from 5 → {new_lives4}")
# Scenario 5: No check-ins in previous week
print("\nScenario 5: No check-ins = no award")
habit_no_checkins = {
"id": "test-habit-4",
"name": "New Habit",
"lives": 2,
"completions": []
}
new_lives5, was_awarded5 = check_and_award_weekly_lives(habit_no_checkins)
assert was_awarded5 == False, "No check-ins = no award"
assert new_lives5 == 2, f"Lives should remain at 2, got {new_lives5}"
print(f"✓ No previous week check-ins: lives remain at {new_lives5}")
print("\n=== All Integration Tests Passed! ===\n")
# Print summary of the feature
print("Feature Summary:")
print("• +1 life awarded per week if habit had ≥1 check-in in previous week")
print("• Monday-Sunday week boundaries (ISO 8601)")
print("• Award triggers on first check-in of current week")
print("• Skips don't count toward recovery")
print("• No cap on lives (can accumulate beyond 3)")
print("• Prevents duplicate awards in same week")
print("")
if __name__ == "__main__":
try:
test_integration_weekly_lives_award()
sys.exit(0)
except AssertionError as e:
print(f"\n✗ Test failed: {e}\n")
sys.exit(1)
except Exception as e:
print(f"\n✗ Unexpected error: {type(e).__name__}: {e}\n")
sys.exit(1)