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['best_streak'] = best_streak
|
||||||
enriched['completion_rate_30d'] = completion_rate
|
enriched['completion_rate_30d'] = completion_rate
|
||||||
enriched['weekly_summary'] = weekly_summary
|
enriched['weekly_summary'] = weekly_summary
|
||||||
|
enriched['should_check_today'] = habits_helpers.should_check_today(habit)
|
||||||
|
|
||||||
enriched_habits.append(enriched)
|
enriched_habits.append(enriched)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,64 @@
|
|||||||
color: var(--text-primary);
|
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 */
|
||||||
.habits-grid {
|
.habits-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -661,6 +719,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 id="habitsContainer">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i data-lucide="loader"></i>
|
<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
|
// Render habits grid
|
||||||
function renderHabits() {
|
function renderHabits() {
|
||||||
const container = document.getElementById('habitsContainer');
|
const container = document.getElementById('habitsContainer');
|
||||||
@@ -863,7 +1043,23 @@
|
|||||||
return;
|
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>`;
|
container.innerHTML = `<div class="habits-grid">${habitsHtml}</div>`;
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
@@ -1662,6 +1858,7 @@
|
|||||||
|
|
||||||
// Initialize page
|
// Initialize page
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
restoreFilters();
|
||||||
loadHabits();
|
loadHabits();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Story US-008: Frontend - Create habit modal with all options
|
|||||||
Story US-009: Frontend - Edit habit modal
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -1147,6 +1148,146 @@ def test_typecheck_us011():
|
|||||||
|
|
||||||
print("✓ Test 65: Typecheck passes (all skip and delete functions defined)")
|
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():
|
def run_all_tests():
|
||||||
"""Run all tests in sequence"""
|
"""Run all tests in sequence"""
|
||||||
tests = [
|
tests = [
|
||||||
@@ -1221,9 +1362,22 @@ def run_all_tests():
|
|||||||
test_delete_toast_message,
|
test_delete_toast_message,
|
||||||
test_skip_delete_no_console_errors,
|
test_skip_delete_no_console_errors,
|
||||||
test_typecheck_us011,
|
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 = []
|
failed = []
|
||||||
for test in tests:
|
for test in tests:
|
||||||
|
|||||||
Reference in New Issue
Block a user