diff --git a/dashboard/habits.html b/dashboard/habits.html
index 4eaea05..015901c 100644
--- a/dashboard/habits.html
+++ b/dashboard/habits.html
@@ -302,20 +302,23 @@
flex-shrink: 0;
}
- .habit-card-check-btn-compact:hover:not(:disabled) {
+ .habit-card-check-btn-compact:hover {
background: var(--accent);
color: white;
transform: scale(1.1);
}
- .habit-card-check-btn-compact:disabled {
- opacity: 0.6;
- cursor: not-allowed;
+ .habit-card-check-btn-compact.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
+ .habit-card-check-btn-compact.checked:hover {
+ opacity: 0.8;
+ transform: scale(1.05);
+ }
+
.habit-card-actions {
display: flex;
gap: var(--space-1);
@@ -1296,12 +1299,12 @@
container.innerHTML = `
${habitsHtml}
`;
lucide.createIcons();
- // Attach event handlers to check-in buttons
+ // Attach event handlers to ALL check-in buttons (for toggle behavior)
habits.forEach(habit => {
- if (!isCheckedToday(habit)) {
- const btn = document.getElementById(`checkin-btn-${habit.id}`);
- if (btn) {
- // Right-click to open detail modal
+ const btn = document.getElementById(`checkin-btn-${habit.id}`);
+ if (btn) {
+ // Right-click to open detail modal (only for unchecked habits)
+ if (!isCheckedToday(habit)) {
btn.addEventListener('contextmenu', (e) => handleCheckInButtonPress(habit.id, e, true));
// Mouse/touch events for long-press detection
@@ -1312,6 +1315,9 @@
btn.addEventListener('touchstart', (e) => handleCheckInButtonPress(habit.id, e, false));
btn.addEventListener('touchend', (e) => handleCheckInButtonRelease(habit.id, e));
btn.addEventListener('touchcancel', () => handleCheckInButtonCancel());
+ } else {
+ // For checked habits, simple click to uncheck
+ btn.addEventListener('click', (e) => checkInHabit(habit.id, e));
}
}
});
@@ -1330,10 +1336,9 @@
${escapeHtml(habit.name)}
🔥 ${habit.streak?.current || 0}
@@ -1967,21 +1972,63 @@
let checkInMood = null;
let longPressTimer = null;
- // Check in habit (simple click)
+ // Check in or uncheck habit (toggle)
async function checkInHabit(habitId, event) {
// Prevent simple check-in if this was triggered during long-press detection
if (event && event.type === 'mousedown') {
return; // Let the long-press handler deal with it
}
+ // Find the habit to check current state
+ const habit = habits.find(h => h.id === habitId);
+ if (!habit) {
+ showToast('Habit not found', 'error');
+ return;
+ }
+
+ const isChecked = isCheckedToday(habit);
+ const today = new Date().toISOString().split('T')[0];
+
+ // Get the check button for optimistic UI update
+ const btn = document.getElementById(`checkin-btn-${habitId}`);
+ const card = event?.target?.closest('.habit-card');
+ const streakElement = card?.querySelector('.habit-card-streak');
+
+ // Store original state for rollback on error
+ const originalButtonText = btn?.innerHTML;
+ const originalButtonDisabled = btn?.disabled;
+ const originalStreakText = streakElement?.textContent;
+
try {
- const response = await fetch(`/echo/api/habits/${habitId}/check`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({})
- });
+ // Optimistic UI update
+ if (btn) {
+ if (isChecked) {
+ // Unchecking - show unchecked state
+ btn.innerHTML = 'â—‹';
+ btn.disabled = false;
+ } else {
+ // Checking - show checked state
+ btn.innerHTML = '✓';
+ btn.disabled = true;
+ }
+ }
+
+ let response;
+ if (isChecked) {
+ // Send DELETE request to uncheck
+ response = await fetch(`/echo/api/habits/${habitId}/check?date=${today}`, {
+ method: 'DELETE'
+ });
+ } else {
+ // Send POST request to check
+ response = await fetch(`/echo/api/habits/${habitId}/check`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({})
+ });
+ }
if (!response.ok) {
const error = await response.json();
@@ -1990,24 +2037,40 @@
const updatedHabit = await response.json();
- // Show success toast with streak
- showToast(`Habit checked! 🔥 Streak: ${updatedHabit.streak?.current || 0}`, 'success');
+ // Update streak display immediately
+ if (streakElement) {
+ streakElement.textContent = `🔥 ${updatedHabit.streak?.current || 0}`;
+ }
- // Refresh habits list
+ // Show success toast with appropriate message
+ if (isChecked) {
+ showToast(`Check-in removed. 🔥 Streak: ${updatedHabit.streak?.current || 0}`, 'success');
+ } else {
+ showToast(`Habit checked! 🔥 Streak: ${updatedHabit.streak?.current || 0}`, 'success');
+ }
+
+ // Refresh habits list to get all updated data
await loadHabits();
// Add pulse animation to the updated card
- setTimeout(() => {
- const card = event?.target?.closest('.habit-card');
- if (card) {
- card.classList.add('pulse');
- setTimeout(() => card.classList.remove('pulse'), 500);
- }
- }, 100);
+ if (card) {
+ card.classList.add('pulse');
+ setTimeout(() => card.classList.remove('pulse'), 500);
+ }
} catch (error) {
- console.error('Failed to check in:', error);
- showToast('Failed to check in: ' + error.message, 'error');
+ console.error('Failed to toggle check-in:', error);
+
+ // Revert optimistic UI update on error
+ if (btn) {
+ btn.innerHTML = originalButtonText;
+ btn.disabled = originalButtonDisabled;
+ }
+ if (streakElement) {
+ streakElement.textContent = originalStreakText;
+ }
+
+ showToast('Failed to toggle check-in: ' + error.message, 'error');
}
}
diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py
index f88ebe2..3bd12f3 100644
--- a/dashboard/tests/test_habits_frontend.py
+++ b/dashboard/tests/test_habits_frontend.py
@@ -1,6 +1,7 @@
"""
Test suite for Habits frontend page structure and navigation
Story US-002: Frontend - Compact habit cards (~100px height)
+Story US-003: Frontend - Check/uncheck toggle behavior
Story US-006: Frontend - Page structure, layout, and navigation link
Story US-007: Frontend - Habit card component
Story US-008: Frontend - Create habit modal with all options
@@ -884,7 +885,7 @@ def test_checkin_toast_with_streak():
# Check that streak value comes from API response
checkin_start = content.find('function checkInHabit')
- checkin_area = content[checkin_start:checkin_start+2000]
+ checkin_area = content[checkin_start:checkin_start+5000] # Increased for toggle implementation
assert 'updatedHabit' in checkin_area or 'await response.json()' in checkin_area, \
"Should get updated habit from response"
@@ -893,26 +894,25 @@ def test_checkin_toast_with_streak():
print("✓ Test 50: Toast shows 'Habit checked! 🔥 Streak: X'")
def test_checkin_button_disabled_after():
- """Test 51: Check-in button becomes disabled after check-in (compact design)"""
+ """Test 51: Check-in button shows checked state after check-in (toggle design)"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
- # Check that renderHabitCard uses isCheckedToday to disable button
+ # Check that renderHabitCard uses isCheckedToday to determine state
assert 'isCheckedToday(habit)' in content, \
"Should check if habit is checked today"
- # Check button uses disabled attribute based on condition
- assert 'isDoneToday ? \'disabled\' : \'\'' in content or \
- '${isDoneToday ? \'disabled\' : \'\'}' in content or \
- 'disabled' in content, \
- "Button should have conditional disabled attribute"
+ # Check button uses 'checked' class instead of disabled (US-003 toggle behavior)
+ assert 'isDoneToday ? \'checked\' : \'\'' in content or \
+ '${isDoneToday ? \'checked\' : \'\'}' in content, \
+ "Button should use 'checked' class (not disabled)"
# Check button displays checkmark when done (compact design)
assert 'isDoneToday ? \'✓\' : \'○\'' in content or \
'${isDoneToday ? \'✓\' : \'○\'}' in content, \
- "Button should show ✓ when disabled, ○ when enabled (compact)"
+ "Button should show ✓ when checked, ○ when unchecked (compact)"
- print("✓ Test 51: Button becomes disabled after check-in (compact design)")
+ print("✓ Test 51: Button shows checked state after check-in (toggle design)")
def test_checkin_pulse_animation():
"""Test 52: Pulse animation plays on card after successful check-in"""
@@ -926,7 +926,7 @@ def test_checkin_pulse_animation():
# Check that checkInHabit adds pulse class
checkin_start = content.find('function checkInHabit')
- checkin_area = content[checkin_start:checkin_start+2000]
+ checkin_area = content[checkin_start:checkin_start+5000] # Increased for toggle implementation
assert 'pulse' in checkin_area or 'classList.add' in checkin_area, \
"Should add pulse class to card after check-in"
@@ -1725,9 +1725,21 @@ def run_all_tests():
test_compact_card_color_accent,
test_compact_viewport_320px,
test_typecheck_us002,
+ # US-003 tests (Check/uncheck toggle)
+ test_check_button_not_disabled_when_checked,
+ test_check_button_clickable_title,
+ test_check_button_checked_css,
+ test_checkin_habit_toggle_logic,
+ test_checkin_optimistic_ui_update,
+ test_checkin_error_reversion,
+ test_streak_updates_after_toggle,
+ test_event_listeners_on_all_buttons,
+ test_toast_messages_for_toggle,
+ test_delete_request_format,
+ test_typecheck_us003,
]
- print(f"\nRunning {len(tests)} frontend tests for US-002, US-006 through US-014...\n")
+ print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-006 through US-014...\n")
failed = []
for test in tests:
@@ -1885,5 +1897,122 @@ def test_typecheck_us002():
assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
print("✓ Test 113: Typecheck passes")
+# ===== US-003: Frontend check/uncheck toggle behavior =====
+
+def test_check_button_not_disabled_when_checked():
+ """Test 114: Check button is not disabled when habit is checked today"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+ # Check that button does not use disabled attribute anymore
+ assert 'isDoneToday ? \'disabled\' : \'\'' not in html, "Button should not use disabled attribute"
+ # Check that button uses 'checked' class instead
+ assert 'isDoneToday ? \'checked\' : \'\'' in html, "Button should use 'checked' class"
+ print("✓ Test 114: Check button is not disabled when checked")
+
+def test_check_button_clickable_title():
+ """Test 115: Check button has clickable title for both states"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+ # Check titles indicate toggle behavior
+ assert 'Click to uncheck' in html, "Checked button should say 'Click to uncheck'"
+ assert 'Check in' in html, "Unchecked button should say 'Check in'"
+ print("✓ Test 115: Button titles indicate toggle behavior")
+
+def test_check_button_checked_css():
+ """Test 116: Check button has CSS for checked state (not disabled)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+ # Check for .checked class styling (not :disabled)
+ assert '.habit-card-check-btn-compact.checked' in html, "Should have .checked class styling"
+ assert 'checked:hover' in html.lower(), "Checked button should have hover state"
+ # Check that old :disabled styling is replaced
+ disabled_count = html.count('.habit-card-check-btn-compact:disabled')
+ assert disabled_count == 0, "Should not have :disabled styling for check button"
+ print("✓ Test 116: Check button uses .checked class, not :disabled")
+
+def test_checkin_habit_toggle_logic():
+ """Test 117: checkInHabit function implements toggle logic"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check that function finds habit and checks current state
+ assert 'const isChecked = isCheckedToday(habit)' in js, "Should check if habit is already checked"
+ # Check that it sends DELETE for unchecking
+ assert "method: 'DELETE'" in js, "Should send DELETE request for uncheck"
+ # Check conditional URL for DELETE
+ assert '/check?date=' in js, "DELETE should include date parameter"
+ print("✓ Test 117: checkInHabit implements toggle logic")
+
+def test_checkin_optimistic_ui_update():
+ """Test 118: Toggle updates UI optimistically before API call"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check for optimistic UI update
+ assert 'btn.innerHTML = \'â—‹\'' in js, "Should update button to unchecked optimistically"
+ assert 'btn.innerHTML = \'✓\'' in js, "Should update button to checked optimistically"
+ # Check for state storage for rollback
+ assert 'originalButtonText' in js, "Should store original button text"
+ assert 'originalButtonDisabled' in js, "Should store original button state"
+ print("✓ Test 118: Toggle uses optimistic UI updates")
+
+def test_checkin_error_reversion():
+ """Test 119: Toggle reverts UI on error"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check for error handling and reversion
+ assert 'btn.innerHTML = originalButtonText' in js, "Should revert button text on error"
+ assert 'btn.disabled = originalButtonDisabled' in js, "Should revert button state on error"
+ assert 'originalStreakText' in js, "Should store original streak for reversion"
+ print("✓ Test 119: Toggle reverts UI on error")
+
+def test_streak_updates_after_toggle():
+ """Test 120: Streak display updates after check/uncheck"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check for streak update
+ assert 'streakElement.textContent' in js, "Should update streak element"
+ assert 'updatedHabit.streak?.current' in js, "Should use updated streak from response"
+ print("✓ Test 120: Streak updates after toggle")
+
+def test_event_listeners_on_all_buttons():
+ """Test 121: Event listeners attached to all check buttons (checked and unchecked)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check that forEach no longer filters by isCheckedToday
+ assert 'habits.forEach(habit =>' in js, "Should iterate all habits"
+ # Check that checked buttons get simple click listener
+ assert "btn.addEventListener('click'," in js, "Checked buttons should have click listener"
+ print("✓ Test 121: Event listeners on all check buttons")
+
+def test_toast_messages_for_toggle():
+ """Test 122: Different toast messages for check vs uncheck"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check for conditional toast messages
+ assert 'Check-in removed' in js or 'removed' in js.lower(), "Should have uncheck message"
+ assert 'Habit checked!' in js, "Should have check message"
+ print("✓ Test 122: Toast messages distinguish check from uncheck")
+
+def test_delete_request_format():
+ """Test 123: DELETE request includes date parameter in query string"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ js = habits_path.read_text()
+ # Check that DELETE URL includes date query param
+ assert '/check?date=${today}' in js, "DELETE should include ?date=YYYY-MM-DD parameter"
+ # Check today is formatted as ISO date
+ assert "toISOString().split('T')[0]" in js, "Should format today as YYYY-MM-DD"
+ print("✓ Test 123: DELETE request has proper date parameter")
+
+def test_typecheck_us003():
+ """Test 124: Typecheck passes for US-003 changes"""
+ repo_root = Path(__file__).parent.parent.parent
+ result = subprocess.run(
+ ['python3', '-m', 'py_compile', 'dashboard/api.py'],
+ cwd=repo_root,
+ capture_output=True,
+ text=True
+ )
+ assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
+ print("✓ Test 124: Typecheck passes")
+
if __name__ == '__main__':
run_all_tests()