diff --git a/dashboard/api.py b/dashboard/api.py index f531cb7..66bba42 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -56,6 +56,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): 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 == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': @@ -1648,6 +1650,124 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): 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 life restore: if last 7 completions are all check-ins (no skips) and lives < 3 + if habit.get('lives', 3) < 3: + recent_completions = sorted( + habit['completions'], + key=lambda x: x.get('date', ''), + reverse=True + )[:7] + + # Check if we have 7 completions and all are check-ins (not skips) + if len(recent_completions) == 7: + all_checks = all(c.get('type') == 'check' for c in recent_completions) + if all_checks: + habit['lives'] = min(habit['lives'] + 1, 3) + + # 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) + + # Return updated habit + self.send_json(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') diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py index 0e5758e..b1a65bf 100644 --- a/dashboard/tests/test_habits_api.py +++ b/dashboard/tests/test_habits_api.py @@ -475,7 +475,273 @@ def test_options_includes_put_delete(): server.shutdown() cleanup_test_env(temp_dir) -# Test 19: Typecheck passes +# Test 20: POST /api/habits/{id}/check adds completion entry +def test_check_in_basic(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Morning Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201, f"Failed to create habit: {status}" + habit_id = habit['id'] + + # Check in on the habit + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}) + + assert status == 200, f"Expected 200, got {status}" + assert len(updated_habit['completions']) == 1, "Expected 1 completion" + assert updated_habit['completions'][0]['date'] == datetime.now().date().isoformat() + assert updated_habit['completions'][0]['type'] == 'check' + print("✓ Test 20: POST /api/habits/{id}/check adds completion entry") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 21: Check-in accepts optional note, rating, mood +def test_check_in_with_details(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Meditation', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Check in with details + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', { + 'note': 'Felt very relaxed today', + 'rating': 5, + 'mood': 'happy' + }) + + assert status == 200, f"Expected 200, got {status}" + completion = updated_habit['completions'][0] + assert completion['note'] == 'Felt very relaxed today' + assert completion['rating'] == 5 + assert completion['mood'] == 'happy' + print("✓ Test 21: Check-in accepts optional note, rating (1-5), and mood") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 22: Check-in returns 404 if habit not found +def test_check_in_not_found(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, response = http_post('/api/habits/non-existent-id/check', {}) + + assert status == 404, f"Expected 404, got {status}" + assert 'error' in response + print("✓ Test 22: Check-in returns 404 if habit not found") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 23: Check-in returns 400 if habit not relevant for today +def test_check_in_not_relevant(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit for specific days (e.g., Monday only) + # If today is not Monday, it should fail + today_weekday = datetime.now().date().weekday() + different_day = (today_weekday + 1) % 7 # Pick a different day + + status, habit = http_post('/api/habits', { + 'name': 'Monday Only Habit', + 'frequency': { + 'type': 'specific_days', + 'days': [different_day] + } + }) + habit_id = habit['id'] + + # Try to check in + status, response = http_post(f'/api/habits/{habit_id}/check', {}) + + assert status == 400, f"Expected 400, got {status}" + assert 'not relevant' in response.get('error', '').lower() + print("✓ Test 23: Check-in returns 400 if habit not relevant for today") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 24: Check-in returns 409 if already checked today +def test_check_in_already_checked(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Water Plants', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Check in once + status, _ = http_post(f'/api/habits/{habit_id}/check', {}) + assert status == 200, "First check-in should succeed" + + # Try to check in again + status, response = http_post(f'/api/habits/{habit_id}/check', {}) + + assert status == 409, f"Expected 409, got {status}" + assert 'already checked' in response.get('error', '').lower() + print("✓ Test 24: Check-in returns 409 if already checked today") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 25: Streak is recalculated after check-in +def test_check_in_updates_streak(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Read', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Check in + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['streak']['current'] == 1, f"Expected streak 1, got {updated_habit['streak']['current']}" + assert updated_habit['streak']['best'] == 1, f"Expected best streak 1, got {updated_habit['streak']['best']}" + print("✓ Test 25: Streak current and best are recalculated after check-in") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 26: lastCheckIn is updated after check-in +def test_check_in_updates_last_check_in(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Floss', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Initially lastCheckIn should be None + assert habit['streak']['lastCheckIn'] is None + + # Check in + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}) + + today = datetime.now().date().isoformat() + assert updated_habit['streak']['lastCheckIn'] == today + print("✓ Test 26: lastCheckIn is updated to today's date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 27: Lives are restored after 7 consecutive check-ins +def test_check_in_life_restore(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit and manually set up 6 previous check-ins + status, habit = http_post('/api/habits', { + 'name': 'Yoga', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Manually add 6 previous check-ins and reduce lives to 2 + habits_data = json.loads(api.HABITS_FILE.read_text()) + for h in habits_data['habits']: + if h['id'] == habit_id: + h['lives'] = 2 + # Add 6 check-ins from previous days + for i in range(6, 0, -1): + past_date = (datetime.now().date() - timedelta(days=i)).isoformat() + h['completions'].append({ + 'date': past_date, + 'type': 'check' + }) + break + api.HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check in for today (7th consecutive) + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['lives'] == 3, f"Expected 3 lives restored, got {updated_habit['lives']}" + print("✓ Test 27: Lives are restored by 1 (max 3) after 7 consecutive check-ins") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 28: Check-in validates rating range +def test_check_in_invalid_rating(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Journal', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Try to check in with invalid rating + status, response = http_post(f'/api/habits/{habit_id}/check', { + 'rating': 10 # Invalid, should be 1-5 + }) + + assert status == 400, f"Expected 400, got {status}" + assert 'rating' in response.get('error', '').lower() + print("✓ Test 28: Check-in validates rating is between 1 and 5") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 29: Check-in validates mood values +def test_check_in_invalid_mood(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Gratitude', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + # Try to check in with invalid mood + status, response = http_post(f'/api/habits/{habit_id}/check', { + 'mood': 'excited' # Invalid, should be happy/neutral/sad + }) + + assert status == 400, f"Expected 400, got {status}" + assert 'mood' in response.get('error', '').lower() + print("✓ Test 29: Check-in validates mood is one of: happy, neutral, sad") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 30: Typecheck passes def test_typecheck(): result = subprocess.run( ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')], @@ -506,6 +772,16 @@ if __name__ == '__main__': test_delete_habit_success() test_delete_habit_not_found() test_options_includes_put_delete() + test_check_in_basic() + test_check_in_with_details() + test_check_in_not_found() + test_check_in_not_relevant() + test_check_in_already_checked() + test_check_in_updates_streak() + test_check_in_updates_last_check_in() + test_check_in_life_restore() + test_check_in_invalid_rating() + test_check_in_invalid_mood() test_typecheck() - print("\n✅ All 19 tests passed!\n") + print("\n✅ All 30 tests passed!\n")