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()