feat: US-013 - Frontend - Stats section and weekly summary

This commit is contained in:
Echo
2026-02-10 17:23:18 +00:00
parent b99c13a325
commit dfc2229091
2 changed files with 467 additions and 1 deletions

View File

@@ -675,6 +675,134 @@
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-muted); 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);
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -754,6 +882,42 @@
</div> </div>
</div> </div>
<!-- Stats Section -->
<div id="statsSection" class="stats-section" style="display: none;">
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total Habits</div>
<div class="stat-value" id="statTotalHabits">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Completion (30d)</div>
<div class="stat-value" id="statAvgCompletion">0%</div>
</div>
<div class="stat-card">
<div class="stat-label">Best Streak 🏆</div>
<div class="stat-value" id="statBestStreak">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Lives</div>
<div class="stat-value" id="statTotalLives">0</div>
</div>
</div>
<div class="weekly-summary">
<div class="weekly-summary-header" onclick="toggleWeeklySummary()">
<div class="weekly-summary-title">Weekly Summary</div>
<i data-lucide="chevron-down" class="weekly-summary-chevron" id="weeklySummaryChevron"></i>
</div>
<div class="weekly-summary-content" id="weeklySummaryContent">
<div class="weekly-chart" id="weeklyChart"></div>
<div class="weekly-stats" id="weeklyStats">
<span id="weeklyCompletedText">0 completed this week</span>
<span id="weeklySkippedText">0 skipped this week</span>
</div>
</div>
</div>
</div>
<div id="habitsContainer"> <div id="habitsContainer">
<div class="empty-state"> <div class="empty-state">
<i data-lucide="loader"></i> <i data-lucide="loader"></i>
@@ -1031,6 +1195,9 @@
function renderHabits() { function renderHabits() {
const container = document.getElementById('habitsContainer'); const container = document.getElementById('habitsContainer');
// Render stats section
renderStats();
if (habits.length === 0) { if (habits.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -1199,6 +1366,114 @@
return 'low'; 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 += `
<div class="weekly-bar-wrapper">
<div class="weekly-bar" style="height: ${height}%" title="${count} completed"></div>
<div class="weekly-day-label">${daysOfWeek[i]}</div>
</div>
`;
}
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 // Modal state
let selectedColor = '#3B82F6'; let selectedColor = '#3B82F6';
let selectedIcon = 'dumbbell'; let selectedIcon = 'dumbbell';

View File

@@ -7,6 +7,7 @@ Story US-009: Frontend - Edit habit modal
Story US-010: Frontend - Check-in interaction (click and long-press) Story US-010: Frontend - Check-in interaction (click and long-press)
Story US-011: Frontend - Skip, lives display, and delete confirmation Story US-011: Frontend - Skip, lives display, and delete confirmation
Story US-012: Frontend - Filter and sort controls Story US-012: Frontend - Filter and sort controls
Story US-013: Frontend - Stats section and weekly summary
""" """
import sys import sys
@@ -1288,6 +1289,183 @@ def test_typecheck_us012():
assert result == 0, "api.py should pass typecheck (syntax check)" assert result == 0, "api.py should pass typecheck (syntax check)"
print("✓ Test 77: Typecheck passes") 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(): def run_all_tests():
"""Run all tests in sequence""" """Run all tests in sequence"""
tests = [ tests = [
@@ -1375,9 +1553,22 @@ def run_all_tests():
test_sort_logic_implementation, test_sort_logic_implementation,
test_backend_provides_should_check_today, test_backend_provides_should_check_today,
test_typecheck_us012, 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 = [] failed = []
for test in tests: for test in tests: