From f3aa97c91088b0bd64122c52b47b01af3c445a87 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 18:59:45 +0000 Subject: [PATCH] feat: US-004 - Frontend: Search and filter collapse to icons --- dashboard/habits.html | 269 +++++++++++++++++++++--- dashboard/tests/test_habits_frontend.py | 254 +++++++++++++++++++++- 2 files changed, 484 insertions(+), 39 deletions(-) diff --git a/dashboard/habits.html b/dashboard/habits.html index 015901c..cba9669 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -31,15 +31,86 @@ } /* Filter bar */ + /* Filter/Search Bar - Collapsible */ .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); + overflow: hidden; + } + + .filter-toolbar { + display: flex; + gap: var(--space-2); + padding: var(--space-2); + min-height: 40px; + } + + .filter-icon-btn { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 36px; + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition-base); + } + + .filter-icon-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--accent); + } + + .filter-icon-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .search-container, .filter-container { + max-height: 0; + overflow: hidden; + transition: max-height 300ms ease, padding 300ms ease; + } + + .search-container.expanded { + max-height: 100px; + padding: 0 var(--space-3) var(--space-3) var(--space-3); + } + + .filter-container.expanded { + max-height: 500px; + padding: 0 var(--space-3) var(--space-3) var(--space-3); + } + + .search-input { + width: 100%; + 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); + transition: all var(--transition-base); + } + + .search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-alpha); + } + + .filter-options { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; } .filter-group { @@ -47,6 +118,7 @@ flex-direction: column; gap: var(--space-1); min-width: 150px; + flex: 1; } .filter-label { @@ -79,7 +151,7 @@ } @media (max-width: 768px) { - .filter-bar { + .filter-options { flex-direction: column; } @@ -917,37 +989,57 @@
-
- - + +
+ +
-
- - + +
+
-
- - + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
@@ -1173,6 +1265,81 @@ } } + // Search and filter collapse/expand + function toggleSearch() { + const searchContainer = document.getElementById('searchContainer'); + const searchToggle = document.getElementById('searchToggle'); + const filterContainer = document.getElementById('filterContainer'); + const filterToggle = document.getElementById('filterToggle'); + const searchInput = document.getElementById('searchInput'); + + const isExpanded = searchContainer.classList.contains('expanded'); + + if (isExpanded) { + // Collapse search + searchContainer.classList.remove('expanded'); + searchToggle.classList.remove('active'); + } else { + // Collapse filter first if open + filterContainer.classList.remove('expanded'); + filterToggle.classList.remove('active'); + + // Expand search + searchContainer.classList.add('expanded'); + searchToggle.classList.add('active'); + + // Focus input after animation + setTimeout(() => searchInput.focus(), 300); + } + } + + function toggleFilters() { + const filterContainer = document.getElementById('filterContainer'); + const filterToggle = document.getElementById('filterToggle'); + const searchContainer = document.getElementById('searchContainer'); + const searchToggle = document.getElementById('searchToggle'); + + const isExpanded = filterContainer.classList.contains('expanded'); + + if (isExpanded) { + // Collapse filters + filterContainer.classList.remove('expanded'); + filterToggle.classList.remove('active'); + } else { + // Collapse search first if open + searchContainer.classList.remove('expanded'); + searchToggle.classList.remove('active'); + + // Expand filters + filterContainer.classList.add('expanded'); + filterToggle.classList.add('active'); + } + } + + function collapseAll() { + const searchContainer = document.getElementById('searchContainer'); + const searchToggle = document.getElementById('searchToggle'); + const filterContainer = document.getElementById('filterContainer'); + const filterToggle = document.getElementById('filterToggle'); + + searchContainer.classList.remove('expanded'); + searchToggle.classList.remove('active'); + filterContainer.classList.remove('expanded'); + filterToggle.classList.remove('active'); + } + + // Search habits by name + function searchHabits(habits, query) { + if (!query || query.trim() === '') { + return habits; + } + + const lowerQuery = query.toLowerCase(); + return habits.filter(habit => + habit.name.toLowerCase().includes(lowerQuery) + ); + } + // Apply filters and sort function applyFiltersAndSort() { const categoryFilter = document.getElementById('categoryFilter').value; @@ -1279,8 +1446,10 @@ return; } - // Apply filters and sort - let filteredHabits = filterHabits(habits); + // Apply search, filters, and sort + const searchQuery = document.getElementById('searchInput').value; + let searchedHabits = searchHabits(habits, searchQuery); + let filteredHabits = filterHabits(searchedHabits); let sortedHabits = sortHabits(filteredHabits); if (sortedHabits.length === 0) { @@ -2251,6 +2420,38 @@ // Initialize page lucide.createIcons(); restoreFilters(); + + // Add event listeners for search/filter collapse + document.getElementById('searchToggle').addEventListener('click', toggleSearch); + document.getElementById('filterToggle').addEventListener('click', toggleFilters); + + // Search input listener + document.getElementById('searchInput').addEventListener('input', () => { + renderHabits(); + }); + + // ESC key to collapse + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + collapseAll(); + } + }); + + // Click outside to collapse + document.addEventListener('click', (e) => { + const filterBar = document.querySelector('.filter-bar'); + const searchContainer = document.getElementById('searchContainer'); + const filterContainer = document.getElementById('filterContainer'); + + // If click is outside filter bar and something is expanded + if (!filterBar.contains(e.target)) { + if (searchContainer.classList.contains('expanded') || + filterContainer.classList.contains('expanded')) { + collapseAll(); + } + } + }); + loadHabits(); diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py index 3bd12f3..9e42986 100644 --- a/dashboard/tests/test_habits_frontend.py +++ b/dashboard/tests/test_habits_frontend.py @@ -1520,16 +1520,17 @@ def test_pickers_wrap_mobile(): print("✓ Test 94: Icon and color pickers wrap properly on mobile") def test_filter_bar_stacks_mobile(): - """Test 95: Filter bar stacks vertically on mobile""" + """Test 95: Filter options stack vertically on mobile""" habits_path = Path(__file__).parent.parent / 'habits.html' content = habits_path.read_text() - # Check for filter bar mobile styles + # Check for filter options mobile styles mobile_section = content[content.find('@media (max-width: 768px)'):content.find('@media (max-width: 768px)') + 3000] - assert '.filter-bar' in mobile_section, "Should have filter-bar mobile styles" - assert 'flex-direction: column' in mobile_section, "Filter bar should stack vertically" - print("✓ Test 95: Filter bar stacks vertically on mobile") + assert '.filter-options' in mobile_section or '.filter-group' in mobile_section, \ + "Should have filter options mobile styles" + assert 'flex-direction: column' in mobile_section, "Filter options should stack vertically" + print("✓ Test 95: Filter options stack vertically on mobile") def test_stats_2x2_mobile(): """Test 96: Stats row shows 2x2 grid on mobile""" @@ -1737,6 +1738,20 @@ def run_all_tests(): test_toast_messages_for_toggle, test_delete_request_format, test_typecheck_us003, + # US-004 tests (Search/filter collapse) + test_filter_toolbar_icons, + test_search_container_collapsible, + test_filter_container_collapsible, + test_toggle_functions_exist, + test_toggle_logic, + test_css_animations, + test_esc_key_listener, + test_click_outside_listener, + test_search_function_exists, + test_search_input_listener, + test_render_habits_uses_search, + test_event_listeners_initialized, + test_typecheck_us004, ] print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-006 through US-014...\n") @@ -2014,5 +2029,234 @@ def test_typecheck_us003(): assert result.returncode == 0, f"Typecheck failed: {result.stderr}" print("✓ Test 124: Typecheck passes") +# ======================================== +# US-004: Frontend - Search and filter collapse to icons +# ======================================== + +def test_filter_toolbar_icons(): + """Test 125: Filter bar shows icon toolbar on page load (~40px height)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for toolbar structure + assert 'filter-toolbar' in content, "Should have filter-toolbar element" + assert 'id="searchToggle"' in content, "Should have search toggle button" + assert 'id="filterToggle"' in content, "Should have filter toggle button" + assert 'data-lucide="search"' in content, "Should have search icon" + assert 'data-lucide="sliders"' in content or 'data-lucide="filter"' in content, \ + "Should have filter/sliders icon" + + # Check toolbar height + assert 'min-height: 40px' in content or 'min-height:40px' in content, \ + "Toolbar should be ~40px height" + + print("✓ Test 125: Filter toolbar with icons exists") + +def test_search_container_collapsible(): + """Test 126: Search container is collapsible with expanded class""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for search container structure + assert 'search-container' in content, "Should have search-container element" + assert 'id="searchContainer"' in content, "Search container should have id" + assert 'id="searchInput"' in content, "Should have search input" + + # Check CSS for collapse/expand states + assert 'max-height: 0' in content, "Collapsed state should have max-height: 0" + assert '.search-container.expanded' in content or '.expanded' in content, \ + "Should have expanded state CSS" + + print("✓ Test 126: Search container is collapsible") + +def test_filter_container_collapsible(): + """Test 127: Filter container is collapsible with expanded class""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for filter container structure + assert 'filter-container' in content, "Should have filter-container element" + assert 'id="filterContainer"' in content, "Filter container should have id" + assert 'filter-options' in content, "Should have filter-options wrapper" + + # Check that filter options are inside container + filter_start = content.find('id="filterContainer"') + category_pos = content.find('id="categoryFilter"') + status_pos = content.find('id="statusFilter"') + sort_pos = content.find('id="sortSelect"') + + assert filter_start < category_pos, "Category filter should be inside filter container" + assert filter_start < status_pos, "Status filter should be inside filter container" + assert filter_start < sort_pos, "Sort select should be inside filter container" + + print("✓ Test 127: Filter container is collapsible") + +def test_toggle_functions_exist(): + """Test 128: toggleSearch, toggleFilters, and collapseAll functions defined""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + assert 'function toggleSearch()' in js, "Should have toggleSearch function" + assert 'function toggleFilters()' in js, "Should have toggleFilters function" + assert 'function collapseAll()' in js, "Should have collapseAll function" + + print("✓ Test 128: Toggle functions are defined") + +def test_toggle_logic(): + """Test 129: Toggle functions add/remove 'expanded' class""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + # Extract toggleSearch function + toggle_start = js.find('function toggleSearch()') + toggle_end = js.find('function toggleFilters()', toggle_start) + toggle_search_code = js[toggle_start:toggle_end] + + assert "classList.contains('expanded')" in toggle_search_code, \ + "Should check for expanded state" + assert "classList.add('expanded')" in toggle_search_code, \ + "Should add expanded class" + assert "classList.remove('expanded')" in toggle_search_code, \ + "Should remove expanded class" + assert "classList.add('active')" in toggle_search_code, \ + "Should add active class to button" + + print("✓ Test 129: Toggle functions implement expand/collapse logic") + +def test_css_animations(): + """Test 130: CSS includes transition animations (300ms ease)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + css = habits_path.read_text() + + # Check for transition properties + assert 'transition:' in css or 'transition :' in css, "Should have CSS transitions" + assert '300ms' in css, "Should have 300ms transition duration" + assert 'ease' in css, "Should use ease timing function" + + # Check for max-height transition specifically + assert 'max-height' in css, "Should animate max-height" + + print("✓ Test 130: CSS animations defined (300ms ease)") + +def test_esc_key_listener(): + """Test 131: ESC key collapses expanded search/filter""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + assert "addEventListener('keydown'" in js, "Should have keydown event listener" + assert "e.key === 'Escape'" in js or 'e.key === "Escape"' in js, \ + "Should check for Escape key" + assert 'collapseAll()' in js, "Should call collapseAll on ESC" + + print("✓ Test 131: ESC key listener implemented") + +def test_click_outside_listener(): + """Test 132: Clicking outside collapses expanded search/filter""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + # Find click outside logic + assert "addEventListener('click'" in js, "Should have click event listener" + assert '.filter-bar' in js, "Should reference filter-bar element" + assert 'contains(e.target)' in js, "Should check if click is outside" + + # Check that it calls collapseAll when clicked outside + # Look for the pattern in the entire file since it might be structured across lines + click_listeners = [] + pos = 0 + while True: + pos = js.find("addEventListener('click'", pos) + if pos == -1: + break + click_listeners.append(js[pos:pos + 800]) + pos += 1 + + # Check if any click listener has the collapse logic + has_collapse = any('collapseAll' in listener for listener in click_listeners) + assert has_collapse, "Should call collapseAll when clicking outside" + + print("✓ Test 132: Click outside listener implemented") + +def test_search_function_exists(): + """Test 133: searchHabits function filters by name""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + assert 'function searchHabits(' in js, "Should have searchHabits function" + + # Check search logic + search_start = js.find('function searchHabits(') + search_end = js.find('function ', search_start + 20) + search_code = js[search_start:search_end] + + assert 'toLowerCase()' in search_code, "Should use case-insensitive search" + assert 'includes(' in search_code, "Should use includes for matching" + assert 'habit.name' in search_code, "Should search by habit name" + + print("✓ Test 133: searchHabits function filters by name") + +def test_search_input_listener(): + """Test 134: Search input triggers renderHabits on input event""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + # Check for search input event listener + assert "getElementById('searchInput')" in js or 'getElementById("searchInput")' in js, \ + "Should get searchInput element" + assert "addEventListener('input'" in js, "Should listen to input events" + assert 'renderHabits()' in js, "Should call renderHabits when searching" + + print("✓ Test 134: Search input listener triggers renderHabits") + +def test_render_habits_uses_search(): + """Test 135: renderHabits applies searchHabits before filters""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + # Find renderHabits function + render_start = js.find('function renderHabits()') + render_section = js[render_start:render_start + 2000] + + assert 'searchHabits(' in render_section, "Should call searchHabits" + assert 'filterHabits(' in render_section, "Should call filterHabits" + + # Check that search happens before filter + search_pos = render_section.find('searchHabits(') + filter_pos = render_section.find('filterHabits(') + assert search_pos < filter_pos, "Search should happen before filtering" + + print("✓ Test 135: renderHabits applies search before filters") + +def test_event_listeners_initialized(): + """Test 136: Toggle button event listeners attached on page load""" + habits_path = Path(__file__).parent.parent / 'habits.html' + js = habits_path.read_text() + + # Find initialization section (near end of script) + init_section = js[-2000:] # Last 2000 chars + + assert "getElementById('searchToggle')" in init_section or \ + 'getElementById("searchToggle")' in init_section, \ + "Should attach listener to searchToggle" + assert "getElementById('filterToggle')" in init_section or \ + 'getElementById("filterToggle")' in init_section, \ + "Should attach listener to filterToggle" + assert 'toggleSearch' in init_section, "Should attach toggleSearch handler" + assert 'toggleFilters' in init_section, "Should attach toggleFilters handler" + + print("✓ Test 136: Event listeners initialized on page load") + +def test_typecheck_us004(): + """Test 137: Typecheck passes for US-004 changes""" + repo_root = Path(__file__).parent.parent.parent + result = subprocess.run( + ['python3', '-m', 'py_compile', 'dashboard/api.py', 'dashboard/habits_helpers.py'], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, f"Typecheck failed: {result.stderr}" + print("✓ Test 137: Typecheck passes") + if __name__ == '__main__': run_all_tests()