From 4f83f39f8343116ef9fbb1b5fb0da7f8a064124d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 29 Sep 2026 00:22:56 +0200 Subject: Add live search to the project routines list --- src/lib/shared/filter-items.test.ts | 53 +++++++++++++++++++++++++++++++++++ src/lib/shared/filter-items.ts | 16 +++++++++++ src/routes/projects/[id]/+page.svelte | 28 ++++++++++++++++-- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/lib/shared/filter-items.test.ts create mode 100644 src/lib/shared/filter-items.ts diff --git a/src/lib/shared/filter-items.test.ts b/src/lib/shared/filter-items.test.ts new file mode 100644 index 0000000..3477cd1 --- /dev/null +++ b/src/lib/shared/filter-items.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { filterByName } from './filter-items'; + +const routines = [ + { id: 1, name: 'Cuisine/Repas' }, + { id: 2, name: 'Cuisine/Vaisselle' }, + { id: 3, name: 'Salle de bain/Douche' }, + { id: 4, name: 'Étirements' }, + { id: 5, name: 'Course' }, +]; + +describe('filterByName', () => { + it('filters by a namespace query like "Cuisine/"', () => { + const res = filterByName(routines, 'Cuisine/'); + expect(res.map((r) => r.name)).toEqual([ + 'Cuisine/Repas', + 'Cuisine/Vaisselle', + ]); + }); + + it('matches a substring anywhere in the name', () => { + const res = filterByName(routines, 'epas'); + expect(res.map((r) => r.name)).toEqual(['Cuisine/Repas']); + }); + + it('is case-insensitive', () => { + expect(filterByName(routines, 'cUiSiNe/')).toHaveLength(2); + }); + + it('folds accents on both sides', () => { + expect(filterByName(routines, 'etirements').map((r) => r.name)).toEqual([ + 'Étirements', + ]); + expect(filterByName(routines, 'Étirements')).toHaveLength(1); + }); + + it('returns everything for an empty or whitespace query, order preserved', () => { + expect(filterByName(routines, '')).toEqual(routines); + expect(filterByName(routines, ' ')).toEqual(routines); + }); + + it('returns nothing when no name matches', () => { + expect(filterByName(routines, 'garage')).toEqual([]); + }); + + it('has no result cap', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + id: i, + name: `Cuisine/Tâche ${i}`, + })); + expect(filterByName(many, 'Cuisine/')).toHaveLength(40); + }); +}); diff --git a/src/lib/shared/filter-items.ts b/src/lib/shared/filter-items.ts new file mode 100644 index 0000000..e7e103b --- /dev/null +++ b/src/lib/shared/filter-items.ts @@ -0,0 +1,16 @@ +/** Lowercase and strip diacritics so "etirements" matches "Étirements". */ +function fold(value: string): string { + return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); +} + +/** Filter `items` to those whose `name` contains `query` as a literal + * substring, case- and accent-insensitive. Order is preserved and no result + * cap applies. Empty/whitespace query returns everything. */ +export function filterByName( + items: T[], + query: string, +): T[] { + const q = fold(query.trim()); + if (q === '') return items; + return items.filter((item) => fold(item.name).includes(q)); +} diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index 8d62a39..7d9a146 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -13,6 +13,7 @@ } from '$lib/shared/routine-metrics'; import SectionTitle from '$lib/components/SectionTitle.svelte'; import { formatDayMonth } from '$lib/shared/temporal'; + import { filterByName } from '$lib/shared/filter-items'; let { data, form } = $props(); @@ -35,6 +36,11 @@ (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), ); } + + let routineSearch = $state(''); + const filteredRoutines = $derived( + filterByName(sorted(data.routines), routineSearch), + );
@@ -102,8 +108,17 @@

{form.error}

{/if} + +
    - {#each sorted(data.routines) as routine (routine.id)} + {#each filteredRoutines as routine (routine.id)}
    @@ -133,8 +148,10 @@ {/each}
- {#if data.routines.length === 0} -

Aucune routine

+ {#if filteredRoutines.length === 0} +

+ {data.routines.length === 0 ? 'Aucune routine' : 'Aucune correspondance.'} +

{/if} {#if data.archived.length > 0} @@ -220,6 +237,11 @@ flex-shrink: 0; } + .routine-search { + width: 100%; + margin-bottom: 0.75rem; + } + .page-footer { margin-top: 2rem; display: flex; -- cgit v1.3