From ca4ee77db6541a36382fb061e31252c61b8f0862 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 11:40:14 +0000 Subject: [PATCH] feat: 5.0 - Backend API - POST /api/habits/{id}/check --- dashboard/api.py | 89 +++++++++++ dashboard/habits.json | 16 +- dashboard/test_habits_check.py | 273 +++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 dashboard/test_habits_check.py diff --git a/dashboard/api.py b/dashboard/api.py index 91a465a..0669bed 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -151,6 +151,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': @@ -881,6 +883,93 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_check(self): + """Mark a habit as completed for today.""" + try: + # Extract habit ID from path: /api/habits/{id}/check + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] # /api/habits/{id}/check -> index 3 is id + + # Get today's date in ISO format (YYYY-MM-DD) + today = datetime.now().date().isoformat() + + # Read habits file + habits_file = KANBAN_DIR / 'habits.json' + if not habits_file.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + self.send_json({'error': 'Habit not found'}, 404) + return + + # Find the habit by ID + habit = None + habit_index = None + for i, h in enumerate(habits_data.get('habits', [])): + if h.get('id') == habit_id: + habit = h + habit_index = i + break + + if habit is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Check if already checked today + completions = habit.get('completions', []) + + # Extract dates from completions (they might be ISO timestamps, we need just the date part) + completion_dates = [] + for comp in completions: + try: + # Parse ISO timestamp and extract date + dt = datetime.fromisoformat(comp.replace('Z', '+00:00')) + completion_dates.append(dt.date().isoformat()) + except (ValueError, AttributeError): + # If parsing fails, assume it's already a date string + completion_dates.append(comp) + + if today in completion_dates: + self.send_json({'error': 'Habit already checked today'}, 400) + return + + # Add today's date to completions + completions.append(today) + + # Sort completions chronologically (oldest first) + completions.sort() + + # Update habit + habit['completions'] = completions + + # Calculate streak + frequency = habit.get('frequency', 'daily') + streak = calculate_streak(completions, frequency) + + # Add streak to response (but don't persist it in JSON) + habit_with_streak = habit.copy() + habit_with_streak['streak'] = streak + + # Update habits data + habits_data['habits'][habit_index] = habit + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 200 OK with updated habit including streak + self.send_json(habit_with_streak, 200) + + except Exception as e: + self.send_json({'error': str(e)}, 500) + def handle_files_get(self): """List files or get file content.""" from urllib.parse import urlparse, parse_qs diff --git a/dashboard/habits.json b/dashboard/habits.json index e54d278..e2e3ac1 100644 --- a/dashboard/habits.json +++ b/dashboard/habits.json @@ -1,4 +1,14 @@ { - "lastUpdated": "2026-02-10T10:57:00.000Z", - "habits": [] -} + "lastUpdated": "2026-02-10T11:40:08.703720", + "habits": [ + { + "id": "habit-1770723608703", + "name": "Water Plants", + "frequency": "daily", + "createdAt": "2026-02-10T11:40:08.703082", + "completions": [ + "2026-02-10" + ] + } + ] +} \ No newline at end of file diff --git a/dashboard/test_habits_check.py b/dashboard/test_habits_check.py new file mode 100644 index 0000000..214333c --- /dev/null +++ b/dashboard/test_habits_check.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +Tests for POST /api/habits/{id}/check endpoint +""" + +import json +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from http.client import HTTPConnection + +# Test against local server +HOST = 'localhost' +PORT = 8088 +HABITS_FILE = Path(__file__).parent / 'habits.json' + + +def cleanup_test_habits(): + """Reset habits.json to empty state for testing.""" + data = { + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + } + HABITS_FILE.write_text(json.dumps(data, indent=2)) + + +def create_test_habit(name='Test Habit', frequency='daily'): + """Helper to create a test habit and return its ID.""" + conn = HTTPConnection(HOST, PORT) + payload = json.dumps({'name': name, 'frequency': frequency}) + headers = {'Content-Type': 'application/json'} + + conn.request('POST', '/api/habits', payload, headers) + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + return data['id'] + + +def test_check_habit_success(): + """Test successfully checking a habit for today.""" + cleanup_test_habits() + habit_id = create_test_habit('Morning Run', 'daily') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 200, f"Expected 200, got {response.status}" + assert data['id'] == habit_id, "Habit ID should match" + assert 'completions' in data, "Response should include completions" + assert len(data['completions']) == 1, "Should have exactly 1 completion" + + # Check that completion date is today (YYYY-MM-DD format) + today = datetime.now().date().isoformat() + assert data['completions'][0] == today, f"Completion should be today's date: {today}" + + # Check that streak is calculated and included + assert 'streak' in data, "Response should include streak" + assert data['streak'] == 1, "Streak should be 1 after first check" + + print("✓ test_check_habit_success") + + +def test_check_habit_already_checked(): + """Test checking a habit that was already checked today.""" + cleanup_test_habits() + habit_id = create_test_habit('Reading', 'daily') + + # Check it once + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + conn.getresponse().read() + conn.close() + + # Try to check again + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 400, f"Expected 400, got {response.status}" + assert 'error' in data, "Response should include error" + assert 'already checked' in data['error'].lower(), "Error should mention already checked" + + print("✓ test_check_habit_already_checked") + + +def test_check_habit_not_found(): + """Test checking a non-existent habit.""" + cleanup_test_habits() + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', '/api/habits/nonexistent-id/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 404, f"Expected 404, got {response.status}" + assert 'error' in data, "Response should include error" + + print("✓ test_check_habit_not_found") + + +def test_check_habit_persistence(): + """Test that completions are persisted to habits.json.""" + cleanup_test_habits() + habit_id = create_test_habit('Meditation', 'daily') + + # Check the habit + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + conn.getresponse().read() + conn.close() + + # Read habits.json directly + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + assert habit is not None, "Habit should exist in file" + assert len(habit['completions']) == 1, "Should have 1 completion in file" + + today = datetime.now().date().isoformat() + assert habit['completions'][0] == today, "Completion date should be today" + + print("✓ test_check_habit_persistence") + + +def test_check_habit_sorted_completions(): + """Test that completions array is sorted chronologically.""" + cleanup_test_habits() + + # Create a habit and manually add out-of-order completions + habit_id = create_test_habit('Workout', 'daily') + + # Manually add past completions in reverse order + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + today = datetime.now().date() + habit['completions'] = [ + (today - timedelta(days=2)).isoformat(), # 2 days ago + (today - timedelta(days=4)).isoformat(), # 4 days ago + (today - timedelta(days=1)).isoformat(), # yesterday + ] + HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check today + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + # Verify completions are sorted oldest first + expected_order = [ + (today - timedelta(days=4)).isoformat(), + (today - timedelta(days=2)).isoformat(), + (today - timedelta(days=1)).isoformat(), + today.isoformat() + ] + + assert data['completions'] == expected_order, f"Completions should be sorted. Got: {data['completions']}" + + print("✓ test_check_habit_sorted_completions") + + +def test_check_habit_streak_calculation(): + """Test that streak is calculated correctly after checking.""" + cleanup_test_habits() + habit_id = create_test_habit('Journaling', 'daily') + + # Add consecutive past completions + today = datetime.now().date() + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + habit['completions'] = [ + (today - timedelta(days=2)).isoformat(), + (today - timedelta(days=1)).isoformat(), + ] + HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check today + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + # Streak should be 3 (today + yesterday + day before) + assert data['streak'] == 3, f"Expected streak 3, got {data['streak']}" + + print("✓ test_check_habit_streak_calculation") + + +def test_check_weekly_habit(): + """Test checking a weekly habit.""" + cleanup_test_habits() + habit_id = create_test_habit('Team Meeting', 'weekly') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 200, f"Expected 200, got {response.status}" + assert len(data['completions']) == 1, "Should have 1 completion" + assert data['streak'] == 1, "Weekly habit should have streak of 1" + + print("✓ test_check_weekly_habit") + + +def test_check_habit_iso_date_format(): + """Test that completion dates use ISO YYYY-MM-DD format (not timestamps).""" + cleanup_test_habits() + habit_id = create_test_habit('Water Plants', 'daily') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + completion = data['completions'][0] + + # Verify format is YYYY-MM-DD (exactly 10 chars, 2 dashes) + assert len(completion) == 10, f"Date should be 10 chars, got {len(completion)}" + assert completion.count('-') == 2, "Date should have 2 dashes" + assert 'T' not in completion, "Date should not include time (no T)" + + # Verify it parses as a valid date + try: + datetime.fromisoformat(completion) + except ValueError: + assert False, f"Completion date should be valid ISO date: {completion}" + + print("✓ test_check_habit_iso_date_format") + + +if __name__ == '__main__': + print("Running tests for POST /api/habits/{id}/check...") + print() + + try: + test_check_habit_success() + test_check_habit_already_checked() + test_check_habit_not_found() + test_check_habit_persistence() + test_check_habit_sorted_completions() + test_check_habit_streak_calculation() + test_check_weekly_habit() + test_check_habit_iso_date_format() + + print() + print("✅ All tests passed!") + sys.exit(0) + except AssertionError as e: + print() + print(f"❌ Test failed: {e}") + sys.exit(1) + except Exception as e: + print() + print(f"❌ Error running tests: {e}") + import traceback + traceback.print_exc() + sys.exit(1)