aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/shared/filter-items.test.ts53
-rw-r--r--src/lib/shared/filter-items.ts16
-rw-r--r--src/routes/projects/[id]/+page.svelte28
3 files changed, 94 insertions, 3 deletions
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<T extends { name: string }>(
+ 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),
+ );
</script>
<main>
@@ -102,8 +108,17 @@
<p class="error">{form.error}</p>
{/if}
+ <input
+ class="form-input routine-search"
+ type="search"
+ bind:value={routineSearch}
+ placeholder="Rechercher une routine..."
+ autocomplete="off"
+ aria-label="Rechercher parmi les routines"
+ />
+
<ul class="item-list">
- {#each sorted(data.routines) as routine (routine.id)}
+ {#each filteredRoutines as routine (routine.id)}
<Row style="flex-wrap: wrap; padding: 0.75rem 1rem;">
<form method="POST" action="?/complete" use:enhance>
<input type="hidden" name="id" value={routine.id} />
@@ -133,8 +148,10 @@
{/each}
</ul>
- {#if data.routines.length === 0}
- <p class="empty">Aucune routine</p>
+ {#if filteredRoutines.length === 0}
+ <p class="empty">
+ {data.routines.length === 0 ? 'Aucune routine' : 'Aucune correspondance.'}
+ </p>
{/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;