feat: US-013 - Frontend - Stats section and weekly summary
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -754,6 +882,42 @@
|
||||
</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 class="empty-state">
|
||||
<i data-lucide="loader"></i>
|
||||
@@ -1031,6 +1195,9 @@
|
||||
function renderHabits() {
|
||||
const container = document.getElementById('habitsContainer');
|
||||
|
||||
// Render stats section
|
||||
renderStats();
|
||||
|
||||
if (habits.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
@@ -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 += `
|
||||
<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
|
||||
let selectedColor = '#3B82F6';
|
||||
let selectedIcon = 'dumbbell';
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user