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

View File

@@ -11,16 +11,22 @@ import sys
import re
import os
import signal
import uuid
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
from datetime import datetime
from pathlib import Path
# Import habits helpers
sys.path.insert(0, str(Path(__file__).parent))
import habits_helpers
BASE_DIR = Path(__file__).parent.parent
TOOLS_DIR = BASE_DIR / 'tools'
NOTES_DIR = BASE_DIR / 'kb' / 'youtube'
KANBAN_DIR = BASE_DIR / 'dashboard'
WORKSPACE_DIR = Path('/home/moltbot/workspace')
HABITS_FILE = KANBAN_DIR / 'habits.json'
# Load .env file if present
_env_file = Path(__file__).parent / '.env'
@@ -48,6 +54,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
self.handle_git_commit()
elif self.path == '/api/pdf':
self.handle_pdf_post()
elif self.path == '/api/habits':
self.handle_habits_post()
elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
self.handle_habits_check()
elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'):
self.handle_habits_skip()
elif self.path == '/api/workspace/run':
self.handle_workspace_run()
elif self.path == '/api/workspace/stop':
@@ -61,6 +73,20 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
else:
self.send_error(404)
def do_PUT(self):
if self.path.startswith('/api/habits/'):
self.handle_habits_put()
else:
self.send_error(404)
def do_DELETE(self):
if self.path.startswith('/api/habits/') and '/check' in self.path:
self.handle_habits_uncheck()
elif self.path.startswith('/api/habits/'):
self.handle_habits_delete()
else:
self.send_error(404)
def handle_git_commit(self):
"""Run git commit and push."""
try:
@@ -251,6 +277,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
self.handle_cron_status()
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
self.handle_activity()
elif self.path == '/api/habits':
self.handle_habits_get()
elif self.path.startswith('/api/files'):
self.handle_files_get()
elif self.path.startswith('/api/diff'):
@@ -1381,6 +1409,546 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_get(self):
"""Get all habits with enriched stats."""
try:
# Read habits file
if not HABITS_FILE.exists():
self.send_json([])
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
habits = data.get('habits', [])
# Enrich each habit with calculated stats
enriched_habits = []
for habit in habits:
# Calculate stats using helpers
current_streak = habits_helpers.calculate_streak(habit)
best_streak = habit.get('streak', {}).get('best', 0)
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
weekly_summary = habits_helpers.get_weekly_summary(habit)
# Add stats to habit
enriched = habit.copy()
enriched['current_streak'] = current_streak
enriched['best_streak'] = best_streak
enriched['completion_rate_30d'] = completion_rate
enriched['weekly_summary'] = weekly_summary
enriched['should_check_today'] = habits_helpers.should_check_today(habit)
enriched_habits.append(enriched)
# Sort by priority ascending (lower number = higher priority)
enriched_habits.sort(key=lambda h: h.get('priority', 999))
self.send_json(enriched_habits)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_post(self):
"""Create a new habit."""
try:
# Read request body
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
data = json.loads(post_data)
# Validate required fields
name = data.get('name', '').strip()
if not name:
self.send_json({'error': 'name is required'}, 400)
return
if len(name) > 100:
self.send_json({'error': 'name must be max 100 characters'}, 400)
return
# Validate color (hex format)
color = data.get('color', '#3b82f6')
if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
return
# Validate frequency type
frequency_type = data.get('frequency', {}).get('type', 'daily')
valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
if frequency_type not in valid_types:
self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
return
# Create new habit
habit_id = str(uuid.uuid4())
now = datetime.now().isoformat()
new_habit = {
'id': habit_id,
'name': name,
'category': data.get('category', 'other'),
'color': color,
'icon': data.get('icon', 'check-circle'),
'priority': data.get('priority', 5),
'notes': data.get('notes', ''),
'reminderTime': data.get('reminderTime', ''),
'frequency': data.get('frequency', {'type': 'daily'}),
'streak': {
'current': 0,
'best': 0,
'lastCheckIn': None
},
'lives': 3,
'completions': [],
'createdAt': now,
'updatedAt': now
}
# Read existing habits
if HABITS_FILE.exists():
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
else:
habits_data = {'lastUpdated': '', 'habits': []}
# Add new habit
habits_data['habits'].append(new_habit)
habits_data['lastUpdated'] = now
# Save to file
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Return created habit with 201 status
self.send_json(new_habit, 201)
except json.JSONDecodeError:
self.send_json({'error': 'Invalid JSON'}, 400)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_put(self):
"""Update an existing habit."""
try:
# Extract habit ID from path
path_parts = self.path.split('/')
if len(path_parts) < 4:
self.send_json({'error': 'Invalid path'}, 400)
return
habit_id = path_parts[3]
# Read request body
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
data = json.loads(post_data)
# Read existing habits
if not HABITS_FILE.exists():
self.send_json({'error': 'Habit not found'}, 404)
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
# Find habit to update
habits = habits_data.get('habits', [])
habit_index = None
for i, habit in enumerate(habits):
if habit['id'] == habit_id:
habit_index = i
break
if habit_index is None:
self.send_json({'error': 'Habit not found'}, 404)
return
# Validate allowed fields
allowed_fields = ['name', 'category', 'color', 'icon', 'priority', 'notes', 'frequency', 'reminderTime']
# Validate name if provided
if 'name' in data:
name = data['name'].strip()
if not name:
self.send_json({'error': 'name cannot be empty'}, 400)
return
if len(name) > 100:
self.send_json({'error': 'name must be max 100 characters'}, 400)
return
# Validate color if provided
if 'color' in data:
color = data['color']
if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
return
# Validate frequency type if provided
if 'frequency' in data:
frequency_type = data.get('frequency', {}).get('type', 'daily')
valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
if frequency_type not in valid_types:
self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
return
# Update only allowed fields
habit = habits[habit_index]
for field in allowed_fields:
if field in data:
habit[field] = data[field]
# Update timestamp
habit['updatedAt'] = datetime.now().isoformat()
# Save to file
habits_data['lastUpdated'] = datetime.now().isoformat()
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Return updated habit
self.send_json(habit)
except json.JSONDecodeError:
self.send_json({'error': 'Invalid JSON'}, 400)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_delete(self):
"""Delete a habit."""
try:
# Extract habit ID from path
path_parts = self.path.split('/')
if len(path_parts) < 4:
self.send_json({'error': 'Invalid path'}, 400)
return
habit_id = path_parts[3]
# Read existing habits
if not HABITS_FILE.exists():
self.send_json({'error': 'Habit not found'}, 404)
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
# Find and remove habit
habits = habits_data.get('habits', [])
habit_found = False
for i, habit in enumerate(habits):
if habit['id'] == habit_id:
habits.pop(i)
habit_found = True
break
if not habit_found:
self.send_json({'error': 'Habit not found'}, 404)
return
# Save to file
habits_data['lastUpdated'] = datetime.now().isoformat()
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Return 204 No Content
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_check(self):
"""Check in on a habit (complete it for today)."""
try:
# Extract habit ID from path (/api/habits/{id}/check)
path_parts = self.path.split('/')
if len(path_parts) < 5:
self.send_json({'error': 'Invalid path'}, 400)
return
habit_id = path_parts[3]
# Read optional body (note, rating, mood)
body_data = {}
content_length = self.headers.get('Content-Length')
if content_length:
post_data = self.rfile.read(int(content_length)).decode('utf-8')
if post_data.strip():
try:
body_data = json.loads(post_data)
except json.JSONDecodeError:
self.send_json({'error': 'Invalid JSON'}, 400)
return
# Read existing habits
if not HABITS_FILE.exists():
self.send_json({'error': 'Habit not found'}, 404)
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
# Find habit
habit = None
for h in habits_data.get('habits', []):
if h['id'] == habit_id:
habit = h
break
if not habit:
self.send_json({'error': 'Habit not found'}, 404)
return
# Verify habit is relevant for today
if not habits_helpers.should_check_today(habit):
self.send_json({'error': 'Habit is not relevant for today based on its frequency'}, 400)
return
# Verify not already checked today
today = datetime.now().date().isoformat()
completions = habit.get('completions', [])
for completion in completions:
if completion.get('date') == today:
self.send_json({'error': 'Habit already checked in today'}, 409)
return
# Create completion entry
completion_entry = {
'date': today,
'type': 'check' # Distinguish from 'skip' for life restore logic
}
# Add optional fields
if 'note' in body_data:
completion_entry['note'] = body_data['note']
if 'rating' in body_data:
rating = body_data['rating']
if not isinstance(rating, int) or rating < 1 or rating > 5:
self.send_json({'error': 'rating must be an integer between 1 and 5'}, 400)
return
completion_entry['rating'] = rating
if 'mood' in body_data:
mood = body_data['mood']
if mood not in ['happy', 'neutral', 'sad']:
self.send_json({'error': 'mood must be one of: happy, neutral, sad'}, 400)
return
completion_entry['mood'] = mood
# Add completion to habit
habit['completions'].append(completion_entry)
# Recalculate streak
current_streak = habits_helpers.calculate_streak(habit)
habit['streak']['current'] = current_streak
# Update best streak if current is higher
if current_streak > habit['streak']['best']:
habit['streak']['best'] = current_streak
# Update lastCheckIn
habit['streak']['lastCheckIn'] = today
# Check for weekly lives recovery (+1 life if ≥1 check-in in previous week)
new_lives, was_awarded = habits_helpers.check_and_award_weekly_lives(habit)
lives_awarded_this_checkin = False
if was_awarded:
habit['lives'] = new_lives
habit['lastLivesAward'] = today
lives_awarded_this_checkin = True
# Update timestamp
habit['updatedAt'] = datetime.now().isoformat()
habits_data['lastUpdated'] = habit['updatedAt']
# Save to file
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Enrich habit with calculated stats before returning
current_streak = habits_helpers.calculate_streak(habit)
best_streak = habit.get('streak', {}).get('best', 0)
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
weekly_summary = habits_helpers.get_weekly_summary(habit)
enriched_habit = habit.copy()
enriched_habit['current_streak'] = current_streak
enriched_habit['best_streak'] = best_streak
enriched_habit['completion_rate_30d'] = completion_rate
enriched_habit['weekly_summary'] = weekly_summary
enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
enriched_habit['livesAwarded'] = lives_awarded_this_checkin
# Return enriched habit
self.send_json(enriched_habit, 200)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_uncheck(self):
"""Uncheck a habit (remove completion for a specific date)."""
try:
# Extract habit ID from path (/api/habits/{id}/check)
path_parts = self.path.split('?')[0].split('/')
if len(path_parts) < 5:
self.send_json({'error': 'Invalid path'}, 400)
return
habit_id = path_parts[3]
# Parse query string for date parameter
parsed = urlparse(self.path)
query_params = parse_qs(parsed.query)
# Get date from query string (required)
if 'date' not in query_params:
self.send_json({'error': 'date parameter is required (format: YYYY-MM-DD)'}, 400)
return
target_date = query_params['date'][0]
# Validate date format
try:
datetime.fromisoformat(target_date)
except ValueError:
self.send_json({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400)
return
# Read existing habits
if not HABITS_FILE.exists():
self.send_json({'error': 'Habit not found'}, 404)
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
# Find habit
habit = None
for h in habits_data.get('habits', []):
if h['id'] == habit_id:
habit = h
break
if not habit:
self.send_json({'error': 'Habit not found'}, 404)
return
# Find and remove the completion for the specified date
completions = habit.get('completions', [])
completion_found = False
for i, completion in enumerate(completions):
if completion.get('date') == target_date:
completions.pop(i)
completion_found = True
break
if not completion_found:
self.send_json({'error': 'No completion found for the specified date'}, 404)
return
# Recalculate streak after removing completion
current_streak = habits_helpers.calculate_streak(habit)
habit['streak']['current'] = current_streak
# Update best streak if needed (best never decreases, but we keep it for consistency)
if current_streak > habit['streak']['best']:
habit['streak']['best'] = current_streak
# Update timestamp
habit['updatedAt'] = datetime.now().isoformat()
habits_data['lastUpdated'] = habit['updatedAt']
# Save to file
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Enrich habit with calculated stats before returning
best_streak = habit.get('streak', {}).get('best', 0)
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
weekly_summary = habits_helpers.get_weekly_summary(habit)
enriched_habit = habit.copy()
enriched_habit['current_streak'] = current_streak
enriched_habit['best_streak'] = best_streak
enriched_habit['completion_rate_30d'] = completion_rate
enriched_habit['weekly_summary'] = weekly_summary
enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
# Return enriched habit
self.send_json(enriched_habit, 200)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_skip(self):
"""Skip a day using a life to preserve streak."""
try:
# Extract habit ID from path (/api/habits/{id}/skip)
path_parts = self.path.split('/')
if len(path_parts) < 5:
self.send_json({'error': 'Invalid path'}, 400)
return
habit_id = path_parts[3]
# Read existing habits
if not HABITS_FILE.exists():
self.send_json({'error': 'Habit not found'}, 404)
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
# Find habit
habit = None
for h in habits_data.get('habits', []):
if h['id'] == habit_id:
habit = h
break
if not habit:
self.send_json({'error': 'Habit not found'}, 404)
return
# Verify lives > 0
current_lives = habit.get('lives', 3)
if current_lives <= 0:
self.send_json({'error': 'No lives remaining'}, 400)
return
# Decrement lives by 1
habit['lives'] = current_lives - 1
# Add completion entry with type='skip'
today = datetime.now().date().isoformat()
completion_entry = {
'date': today,
'type': 'skip'
}
habit['completions'].append(completion_entry)
# Update timestamp
habit['updatedAt'] = datetime.now().isoformat()
habits_data['lastUpdated'] = habit['updatedAt']
# Save to file
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Enrich habit with calculated stats before returning
current_streak = habits_helpers.calculate_streak(habit)
best_streak = habit.get('streak', {}).get('best', 0)
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
weekly_summary = habits_helpers.get_weekly_summary(habit)
enriched_habit = habit.copy()
enriched_habit['current_streak'] = current_streak
enriched_habit['best_streak'] = best_streak
enriched_habit['completion_rate_30d'] = completion_rate
enriched_habit['weekly_summary'] = weekly_summary
enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
# Return enriched habit
self.send_json(enriched_habit, 200)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def send_json(self, data, code=200):
self.send_response(code)
self.send_header('Content-Type', 'application/json')
@@ -1394,7 +1962,7 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()