feat: US-012 - Frontend - Filter and sort controls
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user