@@ -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 += `
+
+ `;
+ }
+
+ 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: