feat: US-012 - Frontend - Filter and sort controls

This commit is contained in:
Echo
2026-02-10 17:15:17 +00:00
parent 8897de25ed
commit b99c13a325
3 changed files with 354 additions and 2 deletions

View File

@@ -1435,6 +1435,7 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
enriched['best_streak'] = best_streak
enriched['completion_rate_30d'] = completion_rate
enriched['weekly_summary'] = weekly_summary
enriched['should_check_today'] = habits_helpers.should_check_today(habit)
enriched_habits.append(enriched)

View File

@@ -30,6 +30,64 @@
color: var(--text-primary);
}
/* Filter bar */
.filter-bar {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 150px;
}
.filter-label {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--transition-base);
}
.filter-select:hover {
border-color: var(--accent);
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-alpha);
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
}
.filter-group {
width: 100%;
}
}
/* Habits grid */
.habits-grid {
display: grid;
@@ -661,6 +719,41 @@
</button>
</div>
<div class="filter-bar">
<div class="filter-group">
<label class="filter-label">Category</label>
<select id="categoryFilter" class="filter-select" onchange="applyFiltersAndSort()">
<option value="all">All</option>
<option value="work">Work</option>
<option value="health">Health</option>
<option value="growth">Growth</option>
<option value="personal">Personal</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">Status</label>
<select id="statusFilter" class="filter-select" onchange="applyFiltersAndSort()">
<option value="all">All</option>
<option value="active_today">Active Today</option>
<option value="done_today">Done Today</option>
<option value="overdue">Overdue</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">Sort By</label>
<select id="sortSelect" class="filter-select" onchange="applyFiltersAndSort()">
<option value="priority_asc">Priority (Low to High)</option>
<option value="priority_desc">Priority (High to Low)</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="streak_desc">Streak (Highest)</option>
<option value="streak_asc">Streak (Lowest)</option>
</select>
</div>
</div>
<div id="habitsContainer">
<div class="empty-state">
<i data-lucide="loader"></i>
@@ -847,6 +940,93 @@
}
}
// Apply filters and sort
function applyFiltersAndSort() {
const categoryFilter = document.getElementById('categoryFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const sortSelect = document.getElementById('sortSelect').value;
// Save to localStorage
localStorage.setItem('habitCategoryFilter', categoryFilter);
localStorage.setItem('habitStatusFilter', statusFilter);
localStorage.setItem('habitSort', sortSelect);
renderHabits();
}
// Restore filters from localStorage
function restoreFilters() {
const categoryFilter = localStorage.getItem('habitCategoryFilter') || 'all';
const statusFilter = localStorage.getItem('habitStatusFilter') || 'all';
const sortSelect = localStorage.getItem('habitSort') || 'priority_asc';
document.getElementById('categoryFilter').value = categoryFilter;
document.getElementById('statusFilter').value = statusFilter;
document.getElementById('sortSelect').value = sortSelect;
}
// Filter habits based on selected filters
function filterHabits(habits) {
const categoryFilter = document.getElementById('categoryFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const today = new Date();
today.setHours(0, 0, 0, 0);
return habits.filter(habit => {
// Category filter
if (categoryFilter !== 'all' && habit.category !== categoryFilter) {
return false;
}
// Status filter
if (statusFilter !== 'all') {
const isDoneToday = isCheckedToday(habit);
const shouldCheckToday = habit.should_check_today; // Added by backend in GET endpoint
if (statusFilter === 'active_today' && !shouldCheckToday) {
return false;
}
if (statusFilter === 'done_today' && !isDoneToday) {
return false;
}
if (statusFilter === 'overdue' && (!shouldCheckToday || isDoneToday)) {
return false;
}
}
return true;
});
}
// Sort habits based on selected sort option
function sortHabits(habits) {
const sortOption = document.getElementById('sortSelect').value;
const sorted = [...habits];
switch (sortOption) {
case 'priority_asc':
sorted.sort((a, b) => (a.priority || 50) - (b.priority || 50));
break;
case 'priority_desc':
sorted.sort((a, b) => (b.priority || 50) - (a.priority || 50));
break;
case 'name_asc':
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'name_desc':
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case 'streak_desc':
sorted.sort((a, b) => (b.current_streak || 0) - (a.current_streak || 0));
break;
case 'streak_asc':
sorted.sort((a, b) => (a.current_streak || 0) - (b.current_streak || 0));
break;
}
return sorted;
}
// Render habits grid
function renderHabits() {
const container = document.getElementById('habitsContainer');
@@ -863,7 +1043,23 @@
return;
}
const habitsHtml = habits.map(habit => renderHabitCard(habit)).join('');
// Apply filters and sort
let filteredHabits = filterHabits(habits);
let sortedHabits = sortHabits(filteredHabits);
if (sortedHabits.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i data-lucide="filter"></i>
<p>No habits match your filters</p>
<p class="hint">Try adjusting the filters above</p>
</div>
`;
lucide.createIcons();
return;
}
const habitsHtml = sortedHabits.map(habit => renderHabitCard(habit)).join('');
container.innerHTML = `<div class="habits-grid">${habitsHtml}</div>`;
lucide.createIcons();
@@ -1662,6 +1858,7 @@
// Initialize page
lucide.createIcons();
restoreFilters();
loadHabits();
</script>
</body>

View File

@@ -6,6 +6,7 @@ Story US-008: Frontend - Create habit modal with all options
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
"""
import sys
@@ -1147,6 +1148,146 @@ def test_typecheck_us011():
print("✓ Test 65: Typecheck passes (all skip and delete functions defined)")
### US-012: Filter and sort controls ###
def test_filter_bar_exists():
"""Test 66: Filter bar with category, status, and sort dropdowns appears above habit grid"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'class="filter-bar"' in content, "Should have filter-bar element"
assert 'id="categoryFilter"' in content, "Should have category filter dropdown"
assert 'id="statusFilter"' in content, "Should have status filter dropdown"
assert 'id="sortSelect"' in content, "Should have sort dropdown"
print("✓ Test 66: Filter bar with dropdowns exists")
def test_category_filter_options():
"""Test 67: Category filter has All, Work, Health, Growth, Personal options"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for category options
assert 'value="all">All</option>' in content, "Should have 'All' option"
assert 'value="work">Work</option>' in content, "Should have 'Work' option"
assert 'value="health">Health</option>' in content, "Should have 'Health' option"
assert 'value="growth">Growth</option>' in content, "Should have 'Growth' option"
assert 'value="personal">Personal</option>' in content, "Should have 'Personal' option"
print("✓ Test 67: Category filter has correct options")
def test_status_filter_options():
"""Test 68: Status filter has All, Active Today, Done Today, Overdue options"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'value="all">All</option>' in content, "Should have 'All' option"
assert 'value="active_today">Active Today</option>' in content, "Should have 'Active Today' option"
assert 'value="done_today">Done Today</option>' in content, "Should have 'Done Today' option"
assert 'value="overdue">Overdue</option>' in content, "Should have 'Overdue' option"
print("✓ Test 68: Status filter has correct options")
def test_sort_dropdown_options():
"""Test 69: Sort dropdown has Priority, Name, and Streak options (asc/desc)"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'value="priority_asc"' in content, "Should have 'Priority (Low to High)' option"
assert 'value="priority_desc"' in content, "Should have 'Priority (High to Low)' option"
assert 'value="name_asc"' in content, "Should have 'Name A-Z' option"
assert 'value="name_desc"' in content, "Should have 'Name Z-A' option"
assert 'value="streak_desc"' in content, "Should have 'Streak (Highest)' option"
assert 'value="streak_asc"' in content, "Should have 'Streak (Lowest)' option"
print("✓ Test 69: Sort dropdown has correct options")
def test_filter_functions_exist():
"""Test 70: applyFiltersAndSort, filterHabits, and sortHabits functions are defined"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'function applyFiltersAndSort()' in content, "Should have applyFiltersAndSort function"
assert 'function filterHabits(' in content, "Should have filterHabits function"
assert 'function sortHabits(' in content, "Should have sortHabits function"
assert 'function restoreFilters()' in content, "Should have restoreFilters function"
print("✓ Test 70: Filter and sort functions are defined")
def test_filter_calls_on_change():
"""Test 71: Filter dropdowns call applyFiltersAndSort on change"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'onchange="applyFiltersAndSort()"' in content, "Filters should call applyFiltersAndSort on change"
# Count how many times onchange appears (should be 3: category, status, sort)
count = content.count('onchange="applyFiltersAndSort()"')
assert count >= 3, f"Should have at least 3 onchange handlers, found {count}"
print("✓ Test 71: Filter dropdowns call applyFiltersAndSort on change")
def test_localstorage_persistence():
"""Test 72: Filter selections are saved to localStorage"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert "localStorage.setItem('habitCategoryFilter'" in content, "Should save category filter to localStorage"
assert "localStorage.setItem('habitStatusFilter'" in content, "Should save status filter to localStorage"
assert "localStorage.setItem('habitSort'" in content, "Should save sort to localStorage"
print("✓ Test 72: Filter selections saved to localStorage")
def test_localstorage_restore():
"""Test 73: Filter selections are restored from localStorage on page load"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert "localStorage.getItem('habitCategoryFilter')" in content, "Should restore category filter from localStorage"
assert "localStorage.getItem('habitStatusFilter')" in content, "Should restore status filter from localStorage"
assert "localStorage.getItem('habitSort')" in content, "Should restore sort from localStorage"
assert 'restoreFilters()' in content, "Should call restoreFilters on page load"
print("✓ Test 73: Filter selections restored from localStorage")
def test_filter_logic_implementation():
"""Test 74: filterHabits function checks category and status correctly"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check category filter logic
assert "categoryFilter !== 'all'" in content, "Should check if category filter is not 'all'"
assert "habit.category" in content, "Should compare habit.category"
# Check status filter logic
assert "statusFilter !== 'all'" in content, "Should check if status filter is not 'all'"
assert "should_check_today" in content or "shouldCheckToday" in content, "Should use should_check_today for status filtering"
print("✓ Test 74: Filter logic checks category and status")
def test_sort_logic_implementation():
"""Test 75: sortHabits function handles all sort options"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check that sort function handles all options
assert "'priority_asc'" in content, "Should handle priority_asc"
assert "'priority_desc'" in content, "Should handle priority_desc"
assert "'name_asc'" in content, "Should handle name_asc"
assert "'name_desc'" in content, "Should handle name_desc"
assert "'streak_desc'" in content, "Should handle streak_desc"
assert "'streak_asc'" in content, "Should handle streak_asc"
assert 'localeCompare' in content, "Should use localeCompare for name sorting"
print("✓ Test 75: Sort logic handles all options")
def test_backend_provides_should_check_today():
"""Test 76: Backend API enriches habits with should_check_today field"""
api_path = Path(__file__).parent.parent / 'api.py'
content = api_path.read_text()
# Check that should_check_today is added in handle_habits_get
assert "should_check_today" in content, "Backend should add should_check_today field"
assert "habits_helpers.should_check_today" in content, "Should use should_check_today helper"
print("✓ Test 76: Backend provides should_check_today field")
def test_typecheck_us012():
"""Test 77: 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 77: Typecheck passes")
def run_all_tests():
"""Run all tests in sequence"""
tests = [
@@ -1221,9 +1362,22 @@ def run_all_tests():
test_delete_toast_message,
test_skip_delete_no_console_errors,
test_typecheck_us011,
# US-012 tests
test_filter_bar_exists,
test_category_filter_options,
test_status_filter_options,
test_sort_dropdown_options,
test_filter_functions_exist,
test_filter_calls_on_change,
test_localstorage_persistence,
test_localstorage_restore,
test_filter_logic_implementation,
test_sort_logic_implementation,
test_backend_provides_should_check_today,
test_typecheck_us012,
]
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, US-010, and US-011...\n")
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, US-010, US-011, and US-012...\n")
failed = []
for test in tests: