diff --git a/dashboard/api.py b/dashboard/api.py index 66bba42..111c630 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -58,6 +58,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): 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': @@ -1768,6 +1770,66 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): 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) + + # 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 b1a65bf..9ab9301 100644 --- a/dashboard/tests/test_habits_api.py +++ b/dashboard/tests/test_habits_api.py @@ -741,7 +741,157 @@ def test_check_in_invalid_mood(): server.shutdown() cleanup_test_env(temp_dir) -# Test 30: Typecheck passes +# Test 30: Skip basic - decrements lives +def test_skip_basic(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201 + habit_id = habit['id'] + + # Skip a day + status, response = http_post(f'/api/habits/{habit_id}/skip', {}) + + assert status == 200, f"Expected 200, got {status}" + assert response['lives'] == 2, f"Expected 2 lives, got {response['lives']}" + + # Verify completion entry was added with type='skip' + completions = response.get('completions', []) + assert len(completions) == 1, f"Expected 1 completion, got {len(completions)}" + assert completions[0]['type'] == 'skip', f"Expected type='skip', got {completions[0].get('type')}" + assert completions[0]['date'] == datetime.now().date().isoformat() + + print("✓ Test 30: Skip decrements lives and adds skip completion") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 31: Skip preserves streak +def test_skip_preserves_streak(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201 + habit_id = habit['id'] + + # Check in to build a streak + http_post(f'/api/habits/{habit_id}/check', {}) + + # Get current streak + status, habits = http_get('/api/habits') + current_streak = habits[0]['current_streak'] + assert current_streak > 0 + + # Skip the next day (simulate by adding skip manually and checking streak doesn't break) + # Since we can't time travel, we'll verify that skip doesn't recalculate streak + status, response = http_post(f'/api/habits/{habit_id}/skip', {}) + + assert status == 200, f"Expected 200, got {status}" + # Verify lives decremented + assert response['lives'] == 2 + # The streak should remain unchanged (skip doesn't break it) + # Note: We can't verify streak preservation perfectly without time travel, + # but we verify the skip completion is added correctly + completions = response.get('completions', []) + skip_count = sum(1 for c in completions if c.get('type') == 'skip') + assert skip_count == 1 + + print("✓ Test 31: Skip preserves streak (doesn't break it)") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 32: Skip returns 404 for non-existent habit +def test_skip_not_found(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + status, response = http_post('/api/habits/nonexistent-id/skip', {}) + + assert status == 404, f"Expected 404, got {status}" + assert 'not found' in response.get('error', '').lower() + + print("✓ Test 32: Skip returns 404 for non-existent habit") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 33: Skip returns 400 when no lives remaining +def test_skip_no_lives(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201 + habit_id = habit['id'] + + # Use all 3 lives + for i in range(3): + status, response = http_post(f'/api/habits/{habit_id}/skip', {}) + assert status == 200, f"Skip {i+1} failed with status {status}" + assert response['lives'] == 2 - i, f"Expected {2-i} lives, got {response['lives']}" + + # Try to skip again with no lives + status, response = http_post(f'/api/habits/{habit_id}/skip', {}) + + assert status == 400, f"Expected 400, got {status}" + assert 'no lives remaining' in response.get('error', '').lower() + + print("✓ Test 33: Skip returns 400 when no lives remaining") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 34: Skip returns updated habit with new lives count +def test_skip_returns_updated_habit(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201 + habit_id = habit['id'] + original_updated_at = habit['updatedAt'] + + # Skip a day + status, response = http_post(f'/api/habits/{habit_id}/skip', {}) + + assert status == 200 + assert response['id'] == habit_id + assert response['lives'] == 2 + assert response['updatedAt'] != original_updated_at, "updatedAt should be updated" + assert 'name' in response + assert 'frequency' in response + assert 'completions' in response + + print("✓ Test 34: Skip returns updated habit with new lives count") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 35: Typecheck passes def test_typecheck(): result = subprocess.run( ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')], @@ -782,6 +932,11 @@ if __name__ == '__main__': test_check_in_life_restore() test_check_in_invalid_rating() test_check_in_invalid_mood() + test_skip_basic() + test_skip_preserves_streak() + test_skip_not_found() + test_skip_no_lives() + test_skip_returns_updated_habit() test_typecheck() - print("\n✅ All 30 tests passed!\n") + print("\n✅ All 35 tests passed!\n")