From dfc22290916db2b6bc8461c2f1b14436eb0c2f87 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 17:23:18 +0000 Subject: [PATCH] feat: US-013 - Frontend - Stats section and weekly summary --- dashboard/habits.html | 275 ++++++++++++++++++++++++ dashboard/tests/test_habits_frontend.py | 193 ++++++++++++++++- 2 files changed, 467 insertions(+), 1 deletion(-) diff --git a/dashboard/habits.html b/dashboard/habits.html index a46fdc3..e540835 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -675,6 +675,134 @@ border-color: var(--accent); background: var(--accent-muted); } + + /* Stats section */ + .stats-section { + margin-bottom: var(--space-4); + } + + .stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-4); + } + + .stat-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + .stat-label { + font-size: var(--text-sm); + color: var(--text-muted); + font-weight: 500; + } + + .stat-value { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--text-primary); + } + + /* Weekly summary */ + .weekly-summary { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + } + + .weekly-summary-header { + padding: var(--space-3) var(--space-4); + background: var(--bg-muted); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background var(--transition-base); + } + + .weekly-summary-header:hover { + background: var(--bg-hover); + } + + .weekly-summary-title { + font-size: var(--text-base); + font-weight: 600; + color: var(--text-primary); + } + + .weekly-summary-chevron { + transition: transform var(--transition-base); + color: var(--text-muted); + } + + .weekly-summary-chevron.expanded { + transform: rotate(180deg); + } + + .weekly-summary-content { + display: none; + padding: var(--space-4); + } + + .weekly-summary-content.visible { + display: block; + } + + .weekly-chart { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-2); + height: 150px; + margin-bottom: var(--space-4); + } + + .weekly-bar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + } + + .weekly-bar { + width: 100%; + background: var(--accent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + transition: all var(--transition-base); + min-height: 4px; + } + + .weekly-bar:hover { + opacity: 0.8; + } + + .weekly-day-label { + font-size: var(--text-xs); + color: var(--text-muted); + font-weight: 500; + } + + .weekly-stats { + display: flex; + gap: var(--space-4); + font-size: var(--text-sm); + color: var(--text-muted); + } + + @media (max-width: 768px) { + .stats-row { + grid-template-columns: repeat(2, 1fr); + } + } @@ -754,6 +882,42 @@ + + +
@@ -1031,6 +1195,9 @@ function renderHabits() { const container = document.getElementById('habitsContainer'); + // Render stats section + renderStats(); + if (habits.length === 0) { container.innerHTML = `
@@ -1199,6 +1366,114 @@ return 'low'; } + // Stats calculation and rendering + function renderStats() { + const statsSection = document.getElementById('statsSection'); + + if (habits.length === 0) { + statsSection.style.display = 'none'; + return; + } + + statsSection.style.display = 'block'; + + // Calculate stats + const totalHabits = habits.length; + + // Average completion rate (30d) across all habits + const avgCompletion = habits.length > 0 + ? Math.round(habits.reduce((sum, h) => sum + (h.completion_rate_30d || 0), 0) / habits.length) + : 0; + + // Best streak across all habits + const bestStreak = Math.max(...habits.map(h => h.streak?.best || 0), 0); + + // Total lives available + const totalLives = habits.reduce((sum, h) => sum + (h.lives || 0), 0); + + // Update DOM + document.getElementById('statTotalHabits').textContent = totalHabits; + document.getElementById('statAvgCompletion').textContent = `${avgCompletion}%`; + document.getElementById('statBestStreak').textContent = bestStreak; + document.getElementById('statTotalLives').textContent = totalLives; + + // Render weekly summary + renderWeeklySummary(); + } + + function renderWeeklySummary() { + const chartContainer = document.getElementById('weeklyChart'); + + // Get current week's data (Mon-Sun) + const today = new Date(); + const currentDayOfWeek = today.getDay(); // 0 = Sunday, 1 = Monday, etc. + const mondayOffset = currentDayOfWeek === 0 ? -6 : 1 - currentDayOfWeek; + const monday = new Date(today); + monday.setDate(today.getDate() + mondayOffset); + monday.setHours(0, 0, 0, 0); + + // Calculate completions per day (Mon-Sun) + const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const completionsPerDay = new Array(7).fill(0); + let weeklyCompleted = 0; + let weeklySkipped = 0; + + habits.forEach(habit => { + (habit.completions || []).forEach(completion => { + const compDate = new Date(completion.date); + compDate.setHours(0, 0, 0, 0); + + // Check if completion is in current week + const daysDiff = Math.floor((compDate - monday) / (1000 * 60 * 60 * 24)); + if (daysDiff >= 0 && daysDiff < 7) { + if (completion.type === 'check') { + completionsPerDay[daysDiff]++; + weeklyCompleted++; + } else if (completion.type === 'skip') { + weeklySkipped++; + } + } + }); + }); + + // Find max for scaling bars + const maxCompletions = Math.max(...completionsPerDay, 1); + + // Render bars + let barsHtml = ''; + for (let i = 0; i < 7; i++) { + const count = completionsPerDay[i]; + const height = (count / maxCompletions) * 100; + barsHtml += ` +
+
+
${daysOfWeek[i]}
+
+ `; + } + + chartContainer.innerHTML = barsHtml; + + // Update weekly stats text + document.getElementById('weeklyCompletedText').textContent = `${weeklyCompleted} completed this week`; + document.getElementById('weeklySkippedText').textContent = `${weeklySkipped} skipped this week`; + + lucide.createIcons(); + } + + function toggleWeeklySummary() { + const content = document.getElementById('weeklySummaryContent'); + const chevron = document.getElementById('weeklySummaryChevron'); + + if (content.classList.contains('visible')) { + content.classList.remove('visible'); + chevron.classList.remove('expanded'); + } else { + content.classList.add('visible'); + chevron.classList.add('expanded'); + } + } + // Modal state let selectedColor = '#3B82F6'; let selectedIcon = 'dumbbell'; diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py index 3cbf1f4..68fc7e2 100644 --- a/dashboard/tests/test_habits_frontend.py +++ b/dashboard/tests/test_habits_frontend.py @@ -7,6 +7,7 @@ Story US-009: Frontend - Edit habit modal Story US-010: Frontend - Check-in interaction (click and long-press) Story US-011: Frontend - Skip, lives display, and delete confirmation Story US-012: Frontend - Filter and sort controls +Story US-013: Frontend - Stats section and weekly summary """ import sys @@ -1288,6 +1289,183 @@ def test_typecheck_us012(): assert result == 0, "api.py should pass typecheck (syntax check)" print("✓ Test 77: Typecheck passes") +# ============================================================================ +# US-013: Frontend - Stats section and weekly summary +# ============================================================================ + +def test_stats_section_exists(): + """Test 78: Stats section exists with 4 metric cards""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'id="statsSection"' in content, "Should have statsSection element" + assert 'class="stats-row"' in content, "Should have stats-row container" + assert 'class="stat-card"' in content, "Should have stat-card elements" + + # Check for the 4 metrics + assert 'id="statTotalHabits"' in content, "Should have Total Habits metric" + assert 'id="statAvgCompletion"' in content, "Should have Avg Completion metric" + assert 'id="statBestStreak"' in content, "Should have Best Streak metric" + assert 'id="statTotalLives"' in content, "Should have Total Lives metric" + + print("✓ Test 78: Stats section with 4 metric cards exists") + +def test_stats_labels_correct(): + """Test 79: Stat cards have correct labels""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'Total Habits' in content, "Should have 'Total Habits' label" + assert 'Avg Completion (30d)' in content or 'Avg Completion' in content, \ + "Should have 'Avg Completion' label" + assert 'Best Streak' in content, "Should have 'Best Streak' label" + assert 'Total Lives' in content, "Should have 'Total Lives' label" + + print("✓ Test 79: Stat cards have correct labels") + +def test_weekly_summary_exists(): + """Test 80: Weekly summary section exists and is collapsible""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'class="weekly-summary"' in content, "Should have weekly-summary section" + assert 'class="weekly-summary-header"' in content, "Should have clickable header" + assert 'Weekly Summary' in content, "Should have 'Weekly Summary' title" + assert 'toggleWeeklySummary()' in content, "Should have toggle function" + assert 'id="weeklySummaryContent"' in content, "Should have collapsible content container" + assert 'id="weeklySummaryChevron"' in content, "Should have chevron icon" + + print("✓ Test 80: Weekly summary section exists and is collapsible") + +def test_weekly_chart_structure(): + """Test 81: Weekly chart displays bars for Mon-Sun""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'id="weeklyChart"' in content, "Should have weeklyChart container" + assert 'class="weekly-chart"' in content, "Should have weekly-chart class" + + # Check for bar rendering in JS + assert 'weekly-bar' in content, "Should render weekly bars in CSS/JS" + assert 'weekly-day-label' in content, "Should have day labels" + + print("✓ Test 81: Weekly chart structure exists") + +def test_weekly_stats_text(): + """Test 82: Weekly stats show completed and skipped counts""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'id="weeklyCompletedText"' in content, "Should have completed count element" + assert 'id="weeklySkippedText"' in content, "Should have skipped count element" + assert 'completed this week' in content, "Should have 'completed this week' text" + assert 'skipped this week' in content, "Should have 'skipped this week' text" + + print("✓ Test 82: Weekly stats text elements exist") + +def test_stats_functions_exist(): + """Test 83: renderStats and renderWeeklySummary functions exist""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'function renderStats()' in content, "Should have renderStats function" + assert 'function renderWeeklySummary()' in content, "Should have renderWeeklySummary function" + assert 'function toggleWeeklySummary()' in content, "Should have toggleWeeklySummary function" + + print("✓ Test 83: Stats rendering functions exist") + +def test_stats_calculations(): + """Test 84: Stats calculations use client-side logic""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for total habits calculation + assert 'totalHabits' in content, "Should calculate total habits" + + # Check for avg completion calculation + assert 'avgCompletion' in content or 'completion_rate_30d' in content, \ + "Should calculate average completion rate" + + # Check for best streak calculation + assert 'bestStreak' in content or 'Math.max' in content, \ + "Should calculate best streak across all habits" + + # Check for total lives calculation + assert 'totalLives' in content or '.lives' in content, \ + "Should calculate total lives" + + print("✓ Test 84: Stats calculations implemented") + +def test_weekly_chart_bars_proportional(): + """Test 85: Weekly chart bars are proportional to completion count""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that bars use height proportional to count + assert 'height' in content and ('style' in content or 'height:' in content), \ + "Should set bar height dynamically" + assert 'maxCompletions' in content or 'Math.max' in content, \ + "Should calculate max for scaling" + + print("✓ Test 85: Weekly chart bars are proportional") + +def test_stats_called_from_render(): + """Test 86: renderStats is called when renderHabits is called""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Find renderHabits function + render_habits_start = content.find('function renderHabits()') + assert render_habits_start > 0, "renderHabits function should exist" + + # Check that renderStats is called within renderHabits + render_habits_section = content[render_habits_start:render_habits_start + 2000] + assert 'renderStats()' in render_habits_section, \ + "renderStats() should be called from renderHabits()" + + print("✓ Test 86: renderStats called from renderHabits") + +def test_stats_css_styling(): + """Test 87: Stats section has proper CSS styling""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert '.stats-section' in content, "Should have stats-section CSS" + assert '.stats-row' in content, "Should have stats-row CSS" + assert '.stat-card' in content, "Should have stat-card CSS" + assert '.weekly-summary' in content, "Should have weekly-summary CSS" + assert '.weekly-chart' in content, "Should have weekly-chart CSS" + assert '.weekly-bar' in content, "Should have weekly-bar CSS" + + print("✓ Test 87: Stats CSS styling exists") + +def test_stats_no_console_errors(): + """Test 88: No obvious console error sources in stats code""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that functions are properly defined + assert 'function renderStats()' in content, "renderStats should be defined" + assert 'function renderWeeklySummary()' in content, "renderWeeklySummary should be defined" + assert 'function toggleWeeklySummary()' in content, "toggleWeeklySummary should be defined" + + # Check DOM element IDs are referenced correctly + assert "getElementById('statsSection')" in content or \ + 'getElementById("statsSection")' in content, \ + "Should reference statsSection element" + assert "getElementById('statTotalHabits')" in content or \ + 'getElementById("statTotalHabits")' in content, \ + "Should reference statTotalHabits element" + + print("✓ Test 88: No obvious console error sources") + +def test_typecheck_us013(): + """Test 89: Typecheck passes for api.py""" + api_path = Path(__file__).parent.parent / 'api.py' + result = os.system(f'python3 -m py_compile {api_path} 2>/dev/null') + assert result == 0, "api.py should pass typecheck (syntax check)" + print("✓ Test 89: Typecheck passes") + def run_all_tests(): """Run all tests in sequence""" tests = [ @@ -1375,9 +1553,22 @@ def run_all_tests(): test_sort_logic_implementation, test_backend_provides_should_check_today, test_typecheck_us012, + # US-013 tests + test_stats_section_exists, + test_stats_labels_correct, + test_weekly_summary_exists, + test_weekly_chart_structure, + test_weekly_stats_text, + test_stats_functions_exist, + test_stats_calculations, + test_weekly_chart_bars_proportional, + test_stats_called_from_render, + test_stats_css_styling, + test_stats_no_console_errors, + test_typecheck_us013, ] - print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, US-010, US-011, and US-012...\n") + print(f"\nRunning {len(tests)} frontend tests for US-006 through US-013...\n") failed = [] for test in tests: