diff --git a/dashboard/habits.html b/dashboard/habits.html index 1a236dd..4eaea05 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -93,14 +93,19 @@ min-height: 44px; } - .habit-card-check-btn { - min-height: 48px; - font-size: var(--text-lg); + .habit-card-check-btn-compact { + min-width: 44px; + min-height: 44px; } - .habit-card-skip-btn { - min-height: 44px; - padding: var(--space-2) var(--space-3); + /* Compact cards stay compact on mobile */ + .habit-card { + min-height: 90px; + max-height: 120px; + } + + .habit-card-name { + font-size: var(--text-xs); } .modal-close { @@ -233,11 +238,13 @@ border: 1px solid var(--border); border-radius: var(--radius-lg); border-left: 4px solid var(--accent); - padding: var(--space-4); + padding: var(--space-3); transition: all var(--transition-base); display: flex; flex-direction: column; - gap: var(--space-3); + gap: var(--space-2); + min-height: 90px; + max-height: 110px; } .habit-card:hover { @@ -246,29 +253,73 @@ box-shadow: var(--shadow-md); } - .habit-card-header { + /* Compact single-row layout */ + .habit-card-row { display: flex; align-items: center; gap: var(--space-2); } .habit-card-icon { - width: 20px; - height: 20px; + width: 18px; + height: 18px; color: var(--text-primary); flex-shrink: 0; } .habit-card-name { flex: 1; - font-size: var(--text-base); + font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .habit-card-streak { + font-size: var(--text-xs); + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + } + + /* Compact check button */ + .habit-card-check-btn-compact { + width: 32px; + height: 32px; + border: 2px solid var(--accent); + background: transparent; + color: var(--accent); + border-radius: 50%; + font-size: var(--text-lg); + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .habit-card-check-btn-compact:hover:not(:disabled) { + background: var(--accent); + color: white; + transform: scale(1.1); + } + + .habit-card-check-btn-compact:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--accent); + border-color: var(--accent); + color: white; } .habit-card-actions { display: flex; - gap: var(--space-2); + gap: var(--space-1); + flex-shrink: 0; } .habit-card-action-btn { @@ -290,123 +341,48 @@ } .habit-card-action-btn svg { - width: 16px; - height: 16px; + width: 14px; + height: 14px; } - .habit-card-streaks { - display: flex; - gap: var(--space-4); - font-size: var(--text-sm); - color: var(--text-muted); - } - - .habit-card-streak { + /* Progress bar row */ + .habit-card-progress-row { display: flex; align-items: center; - gap: var(--space-1); + gap: var(--space-2); } - .habit-card-check-btn { - width: 100%; - padding: var(--space-3); - border: 2px solid var(--accent); - background: var(--accent); - color: white; - border-radius: var(--radius-md); - font-size: var(--text-base); + .habit-card-progress-bar { + flex: 1; + height: 6px; + background: var(--bg-muted); + border-radius: var(--radius-sm); + overflow: hidden; + } + + .habit-card-progress-fill { + height: 100%; + transition: width var(--transition-base); + border-radius: var(--radius-sm); + } + + .habit-card-progress-text { + font-size: var(--text-xs); + color: var(--text-muted); font-weight: 600; - cursor: pointer; - transition: all var(--transition-base); - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-2); + min-width: 32px; + text-align: right; + flex-shrink: 0; } - .habit-card-check-btn:hover:not(:disabled) { - background: var(--accent-hover); - transform: scale(1.02); - } - - .habit-card-check-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - background: var(--bg-muted); - border-color: var(--border); - color: var(--text-muted); - } - - .habit-card-last-check { - font-size: var(--text-sm); - color: var(--text-muted); - text-align: center; - } - - .habit-card-lives { - display: flex; - justify-content: center; - align-items: center; - gap: var(--space-2); - font-size: var(--text-lg); - } - - .habit-card-lives-hearts { - display: flex; - gap: var(--space-1); - } - - .habit-card-skip-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: var(--text-xs); - cursor: pointer; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - transition: all var(--transition-base); - } - - .habit-card-skip-btn:hover:not(:disabled) { - background: var(--bg-hover); - color: var(--text-primary); - } - - .habit-card-skip-btn:disabled { - cursor: not-allowed; - opacity: 0.3; - } - - .habit-card-completion { - font-size: var(--text-sm); - color: var(--text-muted); - text-align: center; - } - - .habit-card-footer { - display: flex; - justify-content: space-between; - align-items: center; - padding-top: var(--space-2); - border-top: 1px solid var(--border); - } - - .habit-card-category { - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - background: var(--bg-muted); - color: var(--text-muted); - } - - .habit-card-priority { + /* Next date row */ + .habit-card-next-date { font-size: var(--text-xs); color: var(--text-muted); - display: flex; - align-items: center; - gap: var(--space-1); + text-align: left; } + /* Keep priority indicator styles for future use */ .priority-indicator { width: 8px; height: 8px; @@ -1344,15 +1320,23 @@ // Render single habit card function renderHabitCard(habit) { const isDoneToday = isCheckedToday(habit); - const lastCheckInfo = getLastCheckInfo(habit); - const livesHtml = renderLives(habit.lives || 3); - const completionRate = habit.completion_rate_30d || 0; + const completionRate = Math.round(habit.completion_rate_30d || 0); + const nextCheckDate = getNextCheckDate(habit); return `
-
+
${escapeHtml(habit.name)} + 🔥 ${habit.streak?.current || 0} +
-
-
- 🔥 ${habit.streak?.current || 0} -
-
- 🏆 ${habit.streak?.best || 0} +
+
+
+ ${completionRate}%
- - -
${lastCheckInfo}
- -
-
${livesHtml}
- -
- -
${completionRate}% (30d)
- - +
${nextCheckDate}
`; } @@ -1456,6 +1408,18 @@ return 'low'; } + // Get next check date text + function getNextCheckDate(habit) { + if (isCheckedToday(habit)) { + return 'Next: Tomorrow'; + } + if (habit.should_check_today) { + return 'Due: Today'; + } + // For habits not due today, show generic "upcoming" + return 'Next: Upcoming'; + } + // Stats calculation and rendering function renderStats() { const statsSection = document.getElementById('statsSection'); diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py index b304776..f88ebe2 100644 --- a/dashboard/tests/test_habits_frontend.py +++ b/dashboard/tests/test_habits_frontend.py @@ -1,5 +1,6 @@ """ Test suite for Habits frontend page structure and navigation +Story US-002: Frontend - Compact habit cards (~100px height) 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 @@ -13,6 +14,7 @@ Story US-014: Frontend - Mobile responsive and touch optimization import sys import os +import subprocess from pathlib import Path # Add parent directory to path for imports @@ -891,7 +893,7 @@ 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 with 'Done today' after check-in""" + """Test 51: Check-in button becomes disabled after check-in (compact design)""" habits_path = Path(__file__).parent.parent / 'habits.html' content = habits_path.read_text() @@ -905,12 +907,12 @@ def test_checkin_button_disabled_after(): 'disabled' in content, \ "Button should have conditional disabled attribute" - # Check button text changes - assert '✓ Done today' in content or 'Done today' in content, \ - "Button should show 'Done today' when disabled" - assert 'Check In' in content, "Button should show 'Check In' when enabled" + # Check button displays checkmark when done (compact design) + assert 'isDoneToday ? \'✓\' : \'○\'' in content or \ + '${isDoneToday ? \'✓\' : \'○\'}' in content, \ + "Button should show ✓ when disabled, ○ when enabled (compact)" - print("✓ Test 51: Button becomes disabled with 'Done today' after check-in") + print("✓ Test 51: Button becomes disabled after check-in (compact design)") def test_checkin_pulse_animation(): """Test 52: Pulse animation plays on card after successful check-in""" @@ -1003,21 +1005,18 @@ def test_typecheck_us010(): # ========== US-011 Tests: Skip, lives display, and delete confirmation ========== def test_skip_button_visible(): - """Test 56: Skip button visible next to lives hearts on each card""" + """Test 56: Skip functionality exists (hidden in compact card view)""" html_path = Path(__file__).parent.parent / 'habits.html' content = html_path.read_text() - # Check skip button CSS class exists - assert '.habit-card-skip-btn' in content, "Skip button CSS class should exist" + # Skip button is hidden in compact view, but skipHabitDay function still exists + assert 'async function skipHabitDay' in content or 'function skipHabitDay' in content, \ + "skipHabitDay function should exist for skip functionality" - # Check skip button is in renderHabitCard - assert 'Skip day' in content, "Skip button text should be 'Skip day'" - assert 'onclick="skipHabitDay' in content, "Skip button should call skipHabitDay function" + # In compact design, skip button and lives are NOT visible on card + # This is correct behavior for US-002 compact cards - # Check lives display structure has both hearts and button - assert 'habit-card-lives-hearts' in content, "Lives hearts should be wrapped in separate div" - - print("✓ Test 56: Skip button visible next to lives hearts on each card") + print("✓ Test 56: Skip functionality exists (hidden in compact card view)") def test_skip_confirmation_dialog(): """Test 57: Clicking skip shows confirmation dialog""" @@ -1048,17 +1047,17 @@ def test_skip_sends_post_and_refreshes(): print("✓ Test 58: Confirming skip sends POST /echo/api/habits/{id}/skip and refreshes card") def test_skip_button_disabled_when_no_lives(): - """Test 59: Skip button disabled when lives is 0 with tooltip 'No lives left'""" + """Test 59: Skip logic handles lives correctly (compact view hides UI)""" html_path = Path(__file__).parent.parent / 'habits.html' content = html_path.read_text() - # Check disabled condition - assert "habit.lives === 0 ? 'disabled' : ''" in content, "Skip button should be disabled when lives is 0" + # Skip functionality exists even though UI is hidden in compact view + # The logic for handling lives still needs to exist + assert 'skipHabitDay' in content, "Skip function should exist" - # Check tooltip - assert 'No lives left' in content, "Tooltip should say 'No lives left' when disabled" + # In compact design, skip button is hidden so this test just verifies function exists - print("✓ Test 59: Skip button disabled when lives is 0 with tooltip 'No lives left'") + print("✓ Test 59: Skip logic exists (UI hidden in compact view)") def test_skip_toast_message(): """Test 60: Toast shows 'Day skipped. X lives remaining.' after skip""" @@ -1495,7 +1494,8 @@ def test_touch_targets_44px(): # Check for minimum touch target sizes in mobile styles assert 'min-width: 44px' in content, "Should have min-width 44px for touch targets" assert 'min-height: 44px' in content, "Should have min-height 44px for touch targets" - assert 'min-height: 48px' in content, "Check-in button should have min-height 48px" + # Compact check button uses 44px on mobile (not 48px) + assert 'habit-card-check-btn-compact' in content, "Compact check button should exist" print("✓ Test 92: Touch targets meet 44px minimum on mobile") def test_modals_scrollable_mobile(): @@ -1712,9 +1712,22 @@ def run_all_tests(): test_mobile_no_console_errors, test_typecheck_us014, test_mobile_day_checkboxes_wrap, + # US-002 tests (Compact cards) + test_compact_card_structure, + test_compact_card_visible_elements, + test_compact_card_hidden_elements, + test_progress_percentage_rounded, + test_compact_card_height_css, + test_compact_button_circle_style, + test_next_check_date_function, + test_compact_card_mobile_friendly, + test_progress_bar_styling, + test_compact_card_color_accent, + test_compact_viewport_320px, + test_typecheck_us002, ] - print(f"\nRunning {len(tests)} frontend tests for US-006 through US-014...\n") + print(f"\nRunning {len(tests)} frontend tests for US-002, US-006 through US-014...\n") failed = [] for test in tests: @@ -1737,5 +1750,140 @@ def run_all_tests(): print(f"SUCCESS: All {len(tests)} tests passed!") sys.exit(0) +# ==================== US-002 Tests ==================== +# Frontend: Compact habit cards (~100px height) + +def test_compact_card_structure(): + """Test 102: Compact card has correct HTML structure""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check for compact card structure with single row + assert 'class="habit-card-row"' in html, "Card should have habit-card-row for compact layout" + assert 'class="habit-card-progress-row"' in html, "Card should have progress-row" + assert 'class="habit-card-next-date"' in html, "Card should have next-date row" + print("✓ Test 102: Compact card structure exists") + +def test_compact_card_visible_elements(): + """Test 103: Card displays only essential visible elements""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Visible elements + assert 'habit-card-icon' in html, "Icon should be visible" + assert 'habit-card-name' in html, "Name should be visible" + assert 'habit-card-streak' in html, "Streak should be visible" + assert 'habit-card-check-btn-compact' in html, "Check button should be visible" + assert 'habit-card-progress' in html, "Progress bar should be visible" + assert 'habit-card-next-date' in html, "Next date should be visible" + print("✓ Test 103: Card displays essential visible elements") + +def test_compact_card_hidden_elements(): + """Test 104: Card hides non-essential elements""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # These should NOT be present in compact design + assert 'habit-card-lives' not in html, "Lives should be hidden in compact view" + assert 'habit-card-skip-btn' not in html, "Skip button should be hidden in compact view" + assert 'habit-card-footer' not in html, "Footer should be hidden in compact view" + assert 'habit-card-category' not in html, "Category should be hidden in compact view" + assert 'habit-card-priority' not in html, "Priority should be hidden in compact view" + assert 'habit-card-last-check' not in html, "Last check info should be hidden in compact view" + print("✓ Test 104: Card hides non-essential elements") + +def test_progress_percentage_rounded(): + """Test 105: Progress percentage is rounded to integer""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check for Math.round in renderHabitCard + assert 'Math.round(habit.completion_rate_30d' in html, "Progress should use Math.round()" + # Check there's no decimal formatting like .toFixed() + assert 'completion_rate_30d || 0).toFixed' not in html, "Should not use toFixed for progress" + print("✓ Test 105: Progress percentage uses Math.round()") + +def test_compact_card_height_css(): + """Test 106: Card has compact height constraints in CSS""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check for height constraints + assert 'min-height: 90px' in html or 'min-height:90px' in html, "Card should have min-height ~90-100px" + assert 'max-height: 110px' in html or 'max-height: 120px' in html, "Card should have max-height constraint" + print("✓ Test 106: Card has compact height CSS constraints") + +def test_compact_button_circle_style(): + """Test 107: Check button is compact circular style""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + assert 'habit-card-check-btn-compact' in html, "Should have compact check button class" + # Check for circular styling + assert 'border-radius: 50%' in html or 'border-radius:50%' in html, "Compact button should be circular" + # Check for compact dimensions + assert any('32px' in html for _ in range(2)), "Compact button should be ~32px" + print("✓ Test 107: Check button is compact circular style") + +def test_next_check_date_function(): + """Test 108: getNextCheckDate function exists and is used""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + assert 'function getNextCheckDate' in html, "getNextCheckDate function should exist" + assert 'getNextCheckDate(habit)' in html, "getNextCheckDate should be called in renderHabitCard" + # Check for expected output strings + assert "'Next: Tomorrow'" in html or '"Next: Tomorrow"' in html, "Should show 'Next: Tomorrow'" + assert "'Due: Today'" in html or '"Due: Today"' in html, "Should show 'Due: Today'" + assert "'Next: Upcoming'" in html or '"Next: Upcoming"' in html, "Should show 'Next: Upcoming'" + print("✓ Test 108: getNextCheckDate function exists and is used") + +def test_compact_card_mobile_friendly(): + """Test 109: Compact cards remain compact on mobile""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check mobile media query maintains compact size + mobile_section = html[html.find('@media (max-width: 768px)'):html.find('@media (max-width: 768px)') + 2000] if '@media (max-width: 768px)' in html else '' + assert 'habit-card-check-btn-compact' in mobile_section, "Mobile styles should include compact button" + assert 'min-height: 44px' in mobile_section, "Mobile touch targets should be 44px" + print("✓ Test 109: Compact cards are mobile-friendly") + +def test_progress_bar_styling(): + """Test 110: Progress bar has proper styling""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + assert 'habit-card-progress-bar' in html, "Progress bar container should exist" + assert 'habit-card-progress-fill' in html, "Progress fill element should exist" + assert 'habit-card-progress-text' in html, "Progress text should exist" + # Check for dynamic width styling + assert 'width: ${completionRate}%' in html, "Progress fill should have dynamic width" + print("✓ Test 110: Progress bar styling exists") + +def test_compact_card_color_accent(): + """Test 111: Card uses habit color for border and progress""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check border-left-color uses habit.color + assert 'border-left-color: ${habit.color}' in html, "Card should use habit color for left border" + # Check progress bar uses habit.color + assert 'background-color: ${habit.color}' in html, "Progress bar should use habit color" + print("✓ Test 111: Card uses habit color for accents") + +def test_compact_viewport_320px(): + """Test 112: Cards render correctly at 320px viewport""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + # Check for text-overflow ellipsis for long names + assert 'text-overflow: ellipsis' in html, "Long names should use ellipsis" + assert 'white-space: nowrap' in html, "Card name should not wrap" + # Check for flex-shrink on action elements + assert 'flex-shrink: 0' in html, "Action buttons should not shrink" + print("✓ Test 112: Cards handle 320px viewport width") + +def test_typecheck_us002(): + """Test 113: Typecheck passes for US-002 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 113: Typecheck passes") + if __name__ == '__main__': run_all_tests()