diff --git a/dashboard/api.py b/dashboard/api.py index 111c630..ae66425 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -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) diff --git a/dashboard/habits.html b/dashboard/habits.html index 40db2ef..a46fdc3 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -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 @@ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
@@ -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 = ` +
+ +

No habits match your filters

+

Try adjusting the filters above

+
+ `; + lucide.createIcons(); + return; + } + + const habitsHtml = sortedHabits.map(habit => renderHabitCard(habit)).join(''); container.innerHTML = `
${habitsHtml}
`; lucide.createIcons(); @@ -1662,6 +1858,7 @@ // Initialize page lucide.createIcons(); + restoreFilters(); loadHabits(); diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py index a67cd19..3cbf1f4 100644 --- a/dashboard/tests/test_habits_frontend.py +++ b/dashboard/tests/test_habits_frontend.py @@ -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' in content, "Should have 'All' option" + assert 'value="work">Work' in content, "Should have 'Work' option" + assert 'value="health">Health' in content, "Should have 'Health' option" + assert 'value="growth">Growth' in content, "Should have 'Growth' option" + assert 'value="personal">Personal' 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' in content, "Should have 'All' option" + assert 'value="active_today">Active Today' in content, "Should have 'Active Today' option" + assert 'value="done_today">Done Today' in content, "Should have 'Done Today' option" + assert 'value="overdue">Overdue' 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: