Feature: Habit Tracker with Streak Calculation #1

Closed
Marius wants to merge 26 commits from feature/habit-tracker into master
26 changed files with 5353 additions and 26 deletions
Showing only changes of commit 0f9c0de1a2 - Show all commits

View File

@@ -166,6 +166,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
else:
self.send_error(404)
def do_DELETE(self):
if self.path.startswith('/api/habits/'):
self.handle_habits_delete()
else:
self.send_error(404)
def handle_git_commit(self):
"""Run git commit and push."""
try:
@@ -988,6 +994,59 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_delete(self):
"""Delete a habit by ID."""
try:
# Extract habit ID from path: /api/habits/{id}
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} -> index 3 is id
# 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_index = None
for i, h in enumerate(habits_data.get('habits', [])):
if h.get('id') == habit_id:
habit_index = i
break
if habit_index is None:
self.send_json({'error': 'Habit not found'}, 404)
return
# Remove the habit
deleted_habit = habits_data['habits'].pop(habit_index)
# Update lastUpdated timestamp
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 success message
self.send_json({
'success': True,
'message': 'Habit deleted successfully',
'id': habit_id
}, 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

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Tests for Story 15.0: Backend - Delete habit endpoint
Tests the DELETE /api/habits/{id} endpoint functionality.
"""
import json
import shutil
import sys
import tempfile
import unittest
from datetime import datetime
from http.server import HTTPServer
from pathlib import Path
from threading import Thread
from time import sleep
from urllib.request import Request, urlopen
from urllib.error import HTTPError
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dashboard.api import TaskBoardHandler
class TestHabitsDelete(unittest.TestCase):
"""Test DELETE /api/habits/{id} endpoint"""
@classmethod
def setUpClass(cls):
"""Start test server"""
# Create temp habits.json
cls.temp_dir = Path(tempfile.mkdtemp())
cls.habits_file = cls.temp_dir / 'habits.json'
cls.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
# Monkey-patch KANBAN_DIR to use temp directory
import dashboard.api as api_module
cls.original_kanban_dir = api_module.KANBAN_DIR
api_module.KANBAN_DIR = cls.temp_dir
# Start server in background thread
cls.port = 9022
cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler)
cls.server_thread = Thread(target=cls.server.serve_forever, daemon=True)
cls.server_thread.start()
sleep(0.5) # Wait for server to start
@classmethod
def tearDownClass(cls):
"""Stop server and cleanup"""
cls.server.shutdown()
# Restore original KANBAN_DIR
import dashboard.api as api_module
api_module.KANBAN_DIR = cls.original_kanban_dir
# Cleanup temp directory
shutil.rmtree(cls.temp_dir)
def setUp(self):
"""Reset habits file before each test"""
self.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
def api_call(self, method, path, body=None):
"""Make API call and return (status, data)"""
url = f'http://127.0.0.1:{self.port}{path}'
headers = {'Content-Type': 'application/json'}
if body:
data = json.dumps(body).encode('utf-8')
req = Request(url, data=data, headers=headers, method=method)
else:
req = Request(url, headers=headers, method=method)
try:
with urlopen(req) as response:
return response.status, json.loads(response.read().decode('utf-8'))
except HTTPError as e:
return e.code, json.loads(e.read().decode('utf-8'))
def test_01_delete_removes_habit_from_file(self):
"""AC1: DELETE /api/habits/{id} removes habit from habits.json"""
# Create two habits
_, habit1 = self.api_call('POST', '/api/habits', {'name': 'Habit 1', 'frequency': 'daily'})
_, habit2 = self.api_call('POST', '/api/habits', {'name': 'Habit 2', 'frequency': 'weekly'})
habit1_id = habit1['id']
habit2_id = habit2['id']
# Delete first habit
status, _ = self.api_call('DELETE', f'/api/habits/{habit1_id}')
self.assertEqual(status, 200)
# Verify it's removed from file
data = json.loads(self.habits_file.read_text())
remaining_ids = [h['id'] for h in data['habits']]
self.assertNotIn(habit1_id, remaining_ids, "Deleted habit still in file")
self.assertIn(habit2_id, remaining_ids, "Other habit was incorrectly deleted")
self.assertEqual(len(data['habits']), 1, "Should have exactly 1 habit remaining")
def test_02_returns_200_with_success_message(self):
"""AC2: Returns 200 with success message"""
# Create a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test Habit', 'frequency': 'daily'})
habit_id = habit['id']
# Delete it
status, response = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 200)
self.assertTrue(response.get('success'), "Response should have success=true")
self.assertIn('message', response, "Response should contain message field")
self.assertEqual(response.get('id'), habit_id, "Response should contain habit ID")
def test_03_returns_404_if_not_found(self):
"""AC3: Returns 404 if habit not found"""
status, response = self.api_call('DELETE', '/api/habits/nonexistent-id')
self.assertEqual(status, 404)
self.assertIn('error', response, "Response should contain error message")
def test_04_updates_lastUpdated_timestamp(self):
"""AC4: Updates lastUpdated timestamp"""
# Get initial timestamp
data_before = json.loads(self.habits_file.read_text())
timestamp_before = data_before['lastUpdated']
sleep(0.1) # Ensure timestamp difference
# Create and delete a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
self.api_call('DELETE', f'/api/habits/{habit["id"]}')
# Check timestamp was updated
data_after = json.loads(self.habits_file.read_text())
timestamp_after = data_after['lastUpdated']
self.assertNotEqual(timestamp_after, timestamp_before, "Timestamp should be updated")
# Verify it's a valid ISO timestamp
try:
datetime.fromisoformat(timestamp_after.replace('Z', '+00:00'))
except ValueError:
self.fail(f"Invalid ISO timestamp: {timestamp_after}")
def test_05_edge_cases(self):
"""AC5: Tests for delete endpoint edge cases"""
# Create a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
habit_id = habit['id']
# Delete it
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 200)
# Try to delete again (should return 404)
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 404, "Should return 404 for already deleted habit")
# Test invalid path (trailing slash)
status, _ = self.api_call('DELETE', '/api/habits/')
self.assertEqual(status, 404, "Should return 404 for invalid path")
def test_06_missing_file_handling(self):
"""Test graceful handling when habits.json is missing"""
# Remove file
self.habits_file.unlink()
# Try to delete
status, response = self.api_call('DELETE', '/api/habits/some-id')
self.assertEqual(status, 404)
self.assertIn('error', response)
# Restore file for cleanup
self.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
if __name__ == '__main__':
# Run tests
suite = unittest.TestLoader().loadTestsFromTestCase(TestHabitsDelete)
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# Print summary
if result.wasSuccessful():
print("\n✅ All Story 15.0 acceptance criteria verified:")
print(" 1. DELETE /api/habits/{id} removes habit from habits.json ✓")
print(" 2. Returns 200 with success message ✓")
print(" 3. Returns 404 if habit not found ✓")
print(" 4. Updates lastUpdated timestamp ✓")
print(" 5. Tests for delete endpoint pass ✓")
sys.exit(0 if result.wasSuccessful() else 1)