feat: 5.0 - Backend API - POST /api/habits/{id}/check
This commit is contained in:
@@ -151,6 +151,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_pdf_post()
|
self.handle_pdf_post()
|
||||||
elif self.path == '/api/habits':
|
elif self.path == '/api/habits':
|
||||||
self.handle_habits_post()
|
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':
|
elif self.path == '/api/workspace/run':
|
||||||
self.handle_workspace_run()
|
self.handle_workspace_run()
|
||||||
elif self.path == '/api/workspace/stop':
|
elif self.path == '/api/workspace/stop':
|
||||||
@@ -881,6 +883,93 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
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):
|
def handle_files_get(self):
|
||||||
"""List files or get file content."""
|
"""List files or get file content."""
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
"lastUpdated": "2026-02-10T10:57:00.000Z",
|
"lastUpdated": "2026-02-10T11:40:08.703720",
|
||||||
"habits": []
|
"habits": [
|
||||||
}
|
{
|
||||||
|
"id": "habit-1770723608703",
|
||||||
|
"name": "Water Plants",
|
||||||
|
"frequency": "daily",
|
||||||
|
"createdAt": "2026-02-10T11:40:08.703082",
|
||||||
|
"completions": [
|
||||||
|
"2026-02-10"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
273
dashboard/test_habits_check.py
Normal file
273
dashboard/test_habits_check.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user