From 6d40d7e24be8d85ab14b2037c3d5059536e89dbf Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 18:25:11 +0000 Subject: [PATCH] feat: US-001 - Backend: DELETE endpoint for uncheck (toggle support) --- dashboard/api.py | 86 ++++++++++++- dashboard/tests/test_habits_api.py | 193 ++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 5 deletions(-) diff --git a/dashboard/api.py b/dashboard/api.py index ae66425..53818ad 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -80,7 +80,9 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): self.send_error(404) def do_DELETE(self): - if self.path.startswith('/api/habits/'): + 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) @@ -1771,6 +1773,88 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): 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) + + # Return updated habit + self.send_json(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: diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py index 9ab9301..e6dc117 100644 --- a/dashboard/tests/test_habits_api.py +++ b/dashboard/tests/test_habits_api.py @@ -91,7 +91,11 @@ def http_delete(path, port=8765): req = urllib.request.Request(url, method='DELETE') try: with urllib.request.urlopen(req) as response: - return response.status, None + # Handle JSON response if present + if response.headers.get('Content-Type') == 'application/json': + return response.status, json.loads(response.read().decode()) + else: + return response.status, None except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} @@ -891,14 +895,189 @@ def test_skip_returns_updated_habit(): server.shutdown() cleanup_test_env(temp_dir) -# Test 35: Typecheck passes +# Test 35: DELETE uncheck - removes completion for specified date +def test_uncheck_removes_completion(): + 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 on a specific date + today = datetime.now().date().isoformat() + status, response = http_post(f'/api/habits/{habit_id}/check', {}) + assert status == 200 + assert len(response['completions']) == 1 + assert response['completions'][0]['date'] == today + + # Uncheck the habit for today + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}') + assert status == 200 + assert len(response['completions']) == 0, "Completion should be removed" + assert response['id'] == habit_id + + print("✓ Test 35: DELETE uncheck removes completion for specified date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 36: DELETE uncheck - returns 404 if no completion for date +def test_uncheck_no_completion_for_date(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create a habit (but don't check in) + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + assert status == 201 + habit_id = habit['id'] + + # Try to uncheck a date with no completion + today = datetime.now().date().isoformat() + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}') + assert status == 404 + assert 'error' in response + assert 'No completion found' in response['error'] + + print("✓ Test 36: DELETE uncheck returns 404 if no completion for date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 37: DELETE uncheck - returns 404 if habit not found +def test_uncheck_habit_not_found(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + today = datetime.now().date().isoformat() + status, response = http_delete(f'/api/habits/nonexistent-id/check?date={today}') + assert status == 404 + assert 'error' in response + assert 'Habit not found' in response['error'] + + print("✓ Test 37: DELETE uncheck returns 404 if habit not found") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 38: DELETE uncheck - recalculates streak correctly +def test_uncheck_recalculates_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 for 3 consecutive days + today = datetime.now().date() + for i in range(3): + check_date = (today - timedelta(days=2-i)).isoformat() + # Manually add completion to the habit + with open(api.HABITS_FILE, 'r') as f: + data = json.load(f) + for h in data['habits']: + if h['id'] == habit_id: + h['completions'].append({'date': check_date, 'type': 'check'}) + with open(api.HABITS_FILE, 'w') as f: + json.dump(data, f) + + # Get habit to verify streak is 3 + status, habit = http_get('/api/habits') + assert status == 200 + habit = [h for h in habit if h['id'] == habit_id][0] + assert habit['current_streak'] == 3 + + # Uncheck the middle day + middle_date = (today - timedelta(days=1)).isoformat() + status, response = http_delete(f'/api/habits/{habit_id}/check?date={middle_date}') + assert status == 200 + + # Streak should now be 1 (only today counts) + assert response['streak']['current'] == 1, f"Expected streak 1, got {response['streak']['current']}" + + print("✓ Test 38: DELETE uncheck recalculates streak correctly") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 39: DELETE uncheck - returns updated habit object +def test_uncheck_returns_updated_habit(): + temp_dir = setup_test_env() + server = start_test_server() + + try: + # Create and check in + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }) + habit_id = habit['id'] + + today = datetime.now().date().isoformat() + status, _ = http_post(f'/api/habits/{habit_id}/check', {}) + + # Uncheck and verify response structure + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}') + assert status == 200 + assert 'id' in response + assert 'name' in response + assert 'completions' in response + assert 'streak' in response + assert 'updatedAt' in response + + print("✓ Test 39: DELETE uncheck returns updated habit object") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 40: DELETE uncheck - requires date parameter +def test_uncheck_requires_date(): + 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'} + }) + habit_id = habit['id'] + + # Try to uncheck without date parameter + status, response = http_delete(f'/api/habits/{habit_id}/check') + assert status == 400 + assert 'error' in response + assert 'date parameter is required' in response['error'] + + print("✓ Test 40: DELETE uncheck requires date parameter") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 41: 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") + print("✓ Test 41: Typecheck passes") if __name__ == '__main__': import subprocess @@ -937,6 +1116,12 @@ if __name__ == '__main__': test_skip_not_found() test_skip_no_lives() test_skip_returns_updated_habit() + test_uncheck_removes_completion() + test_uncheck_no_completion_for_date() + test_uncheck_habit_not_found() + test_uncheck_recalculates_streak() + test_uncheck_returns_updated_habit() + test_uncheck_requires_date() test_typecheck() - print("\n✅ All 35 tests passed!\n") + print("\n✅ All 41 tests passed!\n")