feat: US-004 - Backend API - Check-in endpoint with streak logic
This commit is contained in:
120
dashboard/api.py
120
dashboard/api.py
@@ -56,6 +56,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':
|
||||
@@ -1648,6 +1650,124 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_check(self):
|
||||
"""Check in on a habit (complete it for today)."""
|
||||
try:
|
||||
# Extract habit ID from path (/api/habits/{id}/check)
|
||||
path_parts = self.path.split('/')
|
||||
if len(path_parts) < 5:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
|
||||
habit_id = path_parts[3]
|
||||
|
||||
# Read optional body (note, rating, mood)
|
||||
body_data = {}
|
||||
content_length = self.headers.get('Content-Length')
|
||||
if content_length:
|
||||
post_data = self.rfile.read(int(content_length)).decode('utf-8')
|
||||
if post_data.strip():
|
||||
try:
|
||||
body_data = json.loads(post_data)
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({'error': 'Invalid JSON'}, 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
|
||||
|
||||
# Verify habit is relevant for today
|
||||
if not habits_helpers.should_check_today(habit):
|
||||
self.send_json({'error': 'Habit is not relevant for today based on its frequency'}, 400)
|
||||
return
|
||||
|
||||
# Verify not already checked today
|
||||
today = datetime.now().date().isoformat()
|
||||
completions = habit.get('completions', [])
|
||||
for completion in completions:
|
||||
if completion.get('date') == today:
|
||||
self.send_json({'error': 'Habit already checked in today'}, 409)
|
||||
return
|
||||
|
||||
# Create completion entry
|
||||
completion_entry = {
|
||||
'date': today,
|
||||
'type': 'check' # Distinguish from 'skip' for life restore logic
|
||||
}
|
||||
|
||||
# Add optional fields
|
||||
if 'note' in body_data:
|
||||
completion_entry['note'] = body_data['note']
|
||||
if 'rating' in body_data:
|
||||
rating = body_data['rating']
|
||||
if not isinstance(rating, int) or rating < 1 or rating > 5:
|
||||
self.send_json({'error': 'rating must be an integer between 1 and 5'}, 400)
|
||||
return
|
||||
completion_entry['rating'] = rating
|
||||
if 'mood' in body_data:
|
||||
mood = body_data['mood']
|
||||
if mood not in ['happy', 'neutral', 'sad']:
|
||||
self.send_json({'error': 'mood must be one of: happy, neutral, sad'}, 400)
|
||||
return
|
||||
completion_entry['mood'] = mood
|
||||
|
||||
# Add completion to habit
|
||||
habit['completions'].append(completion_entry)
|
||||
|
||||
# Recalculate streak
|
||||
current_streak = habits_helpers.calculate_streak(habit)
|
||||
habit['streak']['current'] = current_streak
|
||||
|
||||
# Update best streak if current is higher
|
||||
if current_streak > habit['streak']['best']:
|
||||
habit['streak']['best'] = current_streak
|
||||
|
||||
# Update lastCheckIn
|
||||
habit['streak']['lastCheckIn'] = today
|
||||
|
||||
# Check for life restore: if last 7 completions are all check-ins (no skips) and lives < 3
|
||||
if habit.get('lives', 3) < 3:
|
||||
recent_completions = sorted(
|
||||
habit['completions'],
|
||||
key=lambda x: x.get('date', ''),
|
||||
reverse=True
|
||||
)[:7]
|
||||
|
||||
# Check if we have 7 completions and all are check-ins (not skips)
|
||||
if len(recent_completions) == 7:
|
||||
all_checks = all(c.get('type') == 'check' for c in recent_completions)
|
||||
if all_checks:
|
||||
habit['lives'] = min(habit['lives'] + 1, 3)
|
||||
|
||||
# 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')
|
||||
|
||||
@@ -475,7 +475,273 @@ def test_options_includes_put_delete():
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 19: Typecheck passes
|
||||
# Test 20: POST /api/habits/{id}/check adds completion entry
|
||||
def test_check_in_basic():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Morning Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
assert status == 201, f"Failed to create habit: {status}"
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in on the habit
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert len(updated_habit['completions']) == 1, "Expected 1 completion"
|
||||
assert updated_habit['completions'][0]['date'] == datetime.now().date().isoformat()
|
||||
assert updated_habit['completions'][0]['type'] == 'check'
|
||||
print("✓ Test 20: POST /api/habits/{id}/check adds completion entry")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 21: Check-in accepts optional note, rating, mood
|
||||
def test_check_in_with_details():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Meditation',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in with details
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {
|
||||
'note': 'Felt very relaxed today',
|
||||
'rating': 5,
|
||||
'mood': 'happy'
|
||||
})
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
completion = updated_habit['completions'][0]
|
||||
assert completion['note'] == 'Felt very relaxed today'
|
||||
assert completion['rating'] == 5
|
||||
assert completion['mood'] == 'happy'
|
||||
print("✓ Test 21: Check-in accepts optional note, rating (1-5), and mood")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 22: Check-in returns 404 if habit not found
|
||||
def test_check_in_not_found():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
status, response = http_post('/api/habits/non-existent-id/check', {})
|
||||
|
||||
assert status == 404, f"Expected 404, got {status}"
|
||||
assert 'error' in response
|
||||
print("✓ Test 22: Check-in returns 404 if habit not found")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 23: Check-in returns 400 if habit not relevant for today
|
||||
def test_check_in_not_relevant():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit for specific days (e.g., Monday only)
|
||||
# If today is not Monday, it should fail
|
||||
today_weekday = datetime.now().date().weekday()
|
||||
different_day = (today_weekday + 1) % 7 # Pick a different day
|
||||
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Monday Only Habit',
|
||||
'frequency': {
|
||||
'type': 'specific_days',
|
||||
'days': [different_day]
|
||||
}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'not relevant' in response.get('error', '').lower()
|
||||
print("✓ Test 23: Check-in returns 400 if habit not relevant for today")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 24: Check-in returns 409 if already checked today
|
||||
def test_check_in_already_checked():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Water Plants',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in once
|
||||
status, _ = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
assert status == 200, "First check-in should succeed"
|
||||
|
||||
# Try to check in again
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
assert status == 409, f"Expected 409, got {status}"
|
||||
assert 'already checked' in response.get('error', '').lower()
|
||||
print("✓ Test 24: Check-in returns 409 if already checked today")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 25: Streak is recalculated after check-in
|
||||
def test_check_in_updates_streak():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Read',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['streak']['current'] == 1, f"Expected streak 1, got {updated_habit['streak']['current']}"
|
||||
assert updated_habit['streak']['best'] == 1, f"Expected best streak 1, got {updated_habit['streak']['best']}"
|
||||
print("✓ Test 25: Streak current and best are recalculated after check-in")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 26: lastCheckIn is updated after check-in
|
||||
def test_check_in_updates_last_check_in():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Floss',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Initially lastCheckIn should be None
|
||||
assert habit['streak']['lastCheckIn'] is None
|
||||
|
||||
# Check in
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
assert updated_habit['streak']['lastCheckIn'] == today
|
||||
print("✓ Test 26: lastCheckIn is updated to today's date")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 27: Lives are restored after 7 consecutive check-ins
|
||||
def test_check_in_life_restore():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit and manually set up 6 previous check-ins
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Yoga',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Manually add 6 previous check-ins and reduce lives to 2
|
||||
habits_data = json.loads(api.HABITS_FILE.read_text())
|
||||
for h in habits_data['habits']:
|
||||
if h['id'] == habit_id:
|
||||
h['lives'] = 2
|
||||
# Add 6 check-ins from previous days
|
||||
for i in range(6, 0, -1):
|
||||
past_date = (datetime.now().date() - timedelta(days=i)).isoformat()
|
||||
h['completions'].append({
|
||||
'date': past_date,
|
||||
'type': 'check'
|
||||
})
|
||||
break
|
||||
api.HABITS_FILE.write_text(json.dumps(habits_data, indent=2))
|
||||
|
||||
# Check in for today (7th consecutive)
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['lives'] == 3, f"Expected 3 lives restored, got {updated_habit['lives']}"
|
||||
print("✓ Test 27: Lives are restored by 1 (max 3) after 7 consecutive check-ins")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 28: Check-in validates rating range
|
||||
def test_check_in_invalid_rating():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Journal',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in with invalid rating
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {
|
||||
'rating': 10 # Invalid, should be 1-5
|
||||
})
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'rating' in response.get('error', '').lower()
|
||||
print("✓ Test 28: Check-in validates rating is between 1 and 5")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 29: Check-in validates mood values
|
||||
def test_check_in_invalid_mood():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Gratitude',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in with invalid mood
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {
|
||||
'mood': 'excited' # Invalid, should be happy/neutral/sad
|
||||
})
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'mood' in response.get('error', '').lower()
|
||||
print("✓ Test 29: Check-in validates mood is one of: happy, neutral, sad")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cleanup_test_env(temp_dir)
|
||||
|
||||
# Test 30: Typecheck passes
|
||||
def test_typecheck():
|
||||
result = subprocess.run(
|
||||
['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')],
|
||||
@@ -506,6 +772,16 @@ if __name__ == '__main__':
|
||||
test_delete_habit_success()
|
||||
test_delete_habit_not_found()
|
||||
test_options_includes_put_delete()
|
||||
test_check_in_basic()
|
||||
test_check_in_with_details()
|
||||
test_check_in_not_found()
|
||||
test_check_in_not_relevant()
|
||||
test_check_in_already_checked()
|
||||
test_check_in_updates_streak()
|
||||
test_check_in_updates_last_check_in()
|
||||
test_check_in_life_restore()
|
||||
test_check_in_invalid_rating()
|
||||
test_check_in_invalid_mood()
|
||||
test_typecheck()
|
||||
|
||||
print("\n✅ All 19 tests passed!\n")
|
||||
print("\n✅ All 30 tests passed!\n")
|
||||
|
||||
Reference in New Issue
Block a user