diff --git a/dashboard/api.py b/dashboard/api.py index cd93951..2766afa 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -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,8 @@ 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 == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': @@ -251,6 +259,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 +1391,122 @@ 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_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 send_json(self, data, code=200): self.send_response(code) self.send_header('Content-Type', 'application/json') diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py new file mode 100644 index 0000000..f74e583 --- /dev/null +++ b/dashboard/tests/test_habits_api.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Tests for habits API endpoints (GET and POST).""" + +import json +import sys +import tempfile +import shutil +from pathlib import Path +from datetime import datetime, timedelta +from http.server import HTTPServer +import threading +import time +import urllib.request +import urllib.error + +# Add parent directory to path so we can import api module +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Mock the habits file to a temp location for testing +import api +original_habits_file = api.HABITS_FILE + +def setup_test_env(): + """Set up temporary test environment.""" + temp_dir = Path(tempfile.mkdtemp()) + api.HABITS_FILE = temp_dir / 'habits.json' + + # Create empty habits file + api.HABITS_FILE.write_text(json.dumps({ + 'lastUpdated': '', + 'habits': [] + })) + + return temp_dir + +def cleanup_test_env(temp_dir): + """Clean up temporary test environment.""" + api.HABITS_FILE = original_habits_file + shutil.rmtree(temp_dir) + +def start_test_server(port=8765): + """Start test server in background thread.""" + server = HTTPServer(('localhost', port), api.TaskBoardHandler) + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + time.sleep(0.5) # Give server time to start + return server + +def http_get(path, port=8765): + """Make HTTP GET request.""" + url = f'http://localhost:{port}{path}' + try: + with urllib.request.urlopen(url) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +def http_post(path, data, port=8765): + """Make HTTP POST request.""" + url = f'http://localhost:{port}{path}' + req = urllib.request.Request( + url, + data=json.dumps(data).encode(), + headers={'Content-Type': 'application/json'} + ) + try: + with urllib.request.urlopen(req) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +# Test 1: GET /api/habits returns empty array when no habits +def test_get_habits_empty(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_get('/api/habits') + assert status == 200, f"Expected 200, got {status}" + assert data == [], f"Expected empty array, got {data}" + print("✓ Test 1: GET /api/habits returns empty array") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 2: POST /api/habits creates new habit with valid input +def test_post_habit_valid(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + habit_data = { + 'name': 'Morning Exercise', + 'category': 'health', + 'color': '#10b981', + 'icon': 'dumbbell', + 'priority': 1, + 'notes': 'Start with 10 push-ups', + 'reminderTime': '07:00', + 'frequency': { + 'type': 'daily' + } + } + + status, data = http_post('/api/habits', habit_data) + assert status == 201, f"Expected 201, got {status}" + assert 'id' in data, "Response should include habit id" + assert data['name'] == 'Morning Exercise', f"Name mismatch: {data['name']}" + assert data['category'] == 'health', f"Category mismatch: {data['category']}" + assert data['streak']['current'] == 0, "Initial streak should be 0" + assert data['lives'] == 3, "Initial lives should be 3" + assert data['completions'] == [], "Initial completions should be empty" + print("✓ Test 2: POST /api/habits creates habit with 201") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 3: POST validates name is required +def test_post_habit_missing_name(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_post('/api/habits', {}) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'name' in data['error'].lower(), f"Error should mention name: {data['error']}" + print("✓ Test 3: POST validates name is required") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 4: POST validates name max 100 chars +def test_post_habit_name_too_long(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_post('/api/habits', {'name': 'x' * 101}) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert '100' in data['error'], f"Error should mention max length: {data['error']}" + print("✓ Test 4: POST validates name max 100 chars") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 5: POST validates color hex format +def test_post_habit_invalid_color(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_post('/api/habits', { + 'name': 'Test', + 'color': 'not-a-hex-color' + }) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'color' in data['error'].lower(), f"Error should mention color: {data['error']}" + print("✓ Test 5: POST validates color hex format") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 6: POST validates frequency type +def test_post_habit_invalid_frequency(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_post('/api/habits', { + 'name': 'Test', + 'frequency': {'type': 'invalid_type'} + }) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'frequency' in data['error'].lower(), f"Error should mention frequency: {data['error']}" + print("✓ Test 6: POST validates frequency type") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 7: GET /api/habits returns habits with stats enriched +def test_get_habits_with_stats(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit first + habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}} + http_post('/api/habits', habit_data) + + # Get habits + status, data = http_get('/api/habits') + assert status == 200, f"Expected 200, got {status}" + assert len(data) == 1, f"Expected 1 habit, got {len(data)}" + + habit = data[0] + 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" + print("✓ Test 7: GET returns habits with stats enriched") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 8: GET /api/habits sorts by priority ascending +def test_get_habits_sorted_by_priority(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create habits with different priorities + http_post('/api/habits', {'name': 'Low Priority', 'priority': 10}) + http_post('/api/habits', {'name': 'High Priority', 'priority': 1}) + http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5}) + + # Get habits + status, data = http_get('/api/habits') + assert status == 200, f"Expected 200, got {status}" + assert len(data) == 3, f"Expected 3 habits, got {len(data)}" + + # Check sorting + assert data[0]['priority'] == 1, "First should be priority 1" + assert data[1]['priority'] == 5, "Second should be priority 5" + assert data[2]['priority'] == 10, "Third should be priority 10" + print("✓ Test 8: GET sorts habits by priority ascending") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 9: POST returns 400 for invalid JSON +def test_post_habit_invalid_json(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + url = f'http://localhost:8765/api/habits' + req = urllib.request.Request( + url, + data=b'invalid json{', + headers={'Content-Type': 'application/json'} + ) + try: + urllib.request.urlopen(req) + assert False, "Should have raised HTTPError" + except urllib.error.HTTPError as e: + assert e.code == 400, f"Expected 400, got {e.code}" + print("✓ Test 9: POST returns 400 for invalid JSON") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 10: POST initializes streak.current=0 +def test_post_habit_initial_streak(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, data = http_post('/api/habits', {'name': 'Test Habit'}) + assert status == 201, f"Expected 201, got {status}" + assert data['streak']['current'] == 0, "Initial streak.current should be 0" + assert data['streak']['best'] == 0, "Initial streak.best should be 0" + assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None" + print("✓ Test 10: POST initializes streak correctly") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 11: Typecheck passes +def test_typecheck(): + result = subprocess.run( + ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')], + capture_output=True + ) + assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}" + print("✓ Test 11: Typecheck passes") + +if __name__ == '__main__': + import subprocess + + print("\n=== Running Habits API Tests ===\n") + + test_get_habits_empty() + test_post_habit_valid() + test_post_habit_missing_name() + test_post_habit_name_too_long() + test_post_habit_invalid_color() + test_post_habit_invalid_frequency() + test_get_habits_with_stats() + test_get_habits_sorted_by_priority() + test_post_habit_invalid_json() + test_post_habit_initial_streak() + test_typecheck() + + print("\n✅ All 11 tests passed!\n")