diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 19:30:50 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 19:30:50 +0200 |
| commit | 20a7a08e97e75c3a45b3430a74c2931554498f1c (patch) | |
| tree | d74bdeef3d426e563b2127942ab71ae31aaf0707 | |
| parent | 37573109a05a07ac50827759ea63793209baa73a (diff) | |
Extract shared groceries helpers and BestRef component
| -rw-r--r-- | src/lib/components/BestRef.svelte | 16 | ||||
| -rw-r--r-- | src/lib/server/services/grocery-actions.ts | 14 | ||||
| -rw-r--r-- | src/lib/shared/group-by-category.test.ts | 50 | ||||
| -rw-r--r-- | src/lib/shared/group-by-category.ts | 23 | ||||
| -rw-r--r-- | src/routes/groceries/+page.server.ts | 14 | ||||
| -rw-r--r-- | src/routes/groceries/+page.svelte | 30 | ||||
| -rw-r--r-- | src/routes/groceries/catalog/+page.server.ts | 13 | ||||
| -rw-r--r-- | src/routes/groceries/catalog/+page.svelte | 33 |
8 files changed, 115 insertions, 78 deletions
diff --git a/src/lib/components/BestRef.svelte b/src/lib/components/BestRef.svelte new file mode 100644 index 0000000..e194876 --- /dev/null +++ b/src/lib/components/BestRef.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import type { Reference } from '$lib/server/services/types'; + + let { ref }: { ref: Reference } = $props(); +</script> + +<span class="best-ref"> + {ref.reference} + {#if ref.price && ref.unit} + {ref.price.toFixed(2)} + {ref.unit} + {/if} + {#if ref.qualityScore != null} + ({ref.qualityScore}/100) + {/if} +</span> diff --git a/src/lib/server/services/grocery-actions.ts b/src/lib/server/services/grocery-actions.ts new file mode 100644 index 0000000..0966c92 --- /dev/null +++ b/src/lib/server/services/grocery-actions.ts @@ -0,0 +1,14 @@ +import { fail } from '@sveltejs/kit'; +import { toggle } from './groceries'; +import { numericId } from './validation'; + +export async function toggleGroceryAction(request: Request) { + const data = await request.formData(); + try { + const id = numericId(data.get('id')); + await toggle(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } + return { success: true }; +} diff --git a/src/lib/shared/group-by-category.test.ts b/src/lib/shared/group-by-category.test.ts new file mode 100644 index 0000000..d9bb379 --- /dev/null +++ b/src/lib/shared/group-by-category.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { groupByCategory } from './group-by-category'; + +type Item = { id: number; name: string; categoryName: string | null }; +type Cat = { id: number; name: string }; + +describe('groupByCategory', () => { + const categories: Cat[] = [ + { id: 1, name: 'Laitier' }, + { id: 2, name: 'Fruits' }, + { id: 3, name: 'Boulangerie' }, + ]; + + it('groups items by category, ordered by category order', () => { + const items: Item[] = [ + { id: 1, name: 'banane', categoryName: 'Fruits' }, + { id: 2, name: 'lait', categoryName: 'Laitier' }, + { id: 3, name: 'pomme', categoryName: 'Fruits' }, + ]; + + const result = groupByCategory(items, categories); + + expect(result.map(([cat]) => cat)).toEqual(['Laitier', 'Fruits']); + expect(result[0][1].map((i) => i.name)).toEqual(['lait']); + expect(result[1][1].map((i) => i.name)).toEqual(['banane', 'pomme']); + }); + + it('prepends a non-catégorisé group for items without a category', () => { + const items: Item[] = [ + { id: 1, name: 'miel', categoryName: null }, + { id: 2, name: 'lait', categoryName: 'Laitier' }, + ]; + + const result = groupByCategory(items, categories); + + expect(result.map(([cat]) => cat)).toEqual(['Non catégorisé', 'Laitier']); + }); + + it('omits categories without any item', () => { + const items: Item[] = [{ id: 1, name: 'lait', categoryName: 'Laitier' }]; + + const result = groupByCategory(items, categories); + + expect(result.map(([cat]) => cat)).toEqual(['Laitier']); + }); + + it('returns no groups for empty items', () => { + expect(groupByCategory([], categories)).toEqual([]); + }); +}); diff --git a/src/lib/shared/group-by-category.ts b/src/lib/shared/group-by-category.ts new file mode 100644 index 0000000..e9b0acd --- /dev/null +++ b/src/lib/shared/group-by-category.ts @@ -0,0 +1,23 @@ +export function groupByCategory< + TItem extends { categoryName: string | null }, + TCategory extends { name: string }, +>(items: TItem[], categories: TCategory[]): [string, TItem[]][] { + const groups = new Map<string, TItem[]>(); + for (const item of items) { + const key = item.categoryName ?? 'Non catégorisé'; + const list = groups.get(key); + if (list) list.push(item); + else groups.set(key, [item]); + } + + const result: [string, TItem[]][] = []; + for (const cat of categories) { + const list = groups.get(cat.name); + if (list?.length) result.push([cat.name, list]); + } + const uncategorized = groups.get('Non catégorisé'); + if (uncategorized?.length) { + result.unshift(['Non catégorisé', uncategorized]); + } + return result; +} diff --git a/src/routes/groceries/+page.server.ts b/src/routes/groceries/+page.server.ts index e462ab2..d0e0759 100644 --- a/src/routes/groceries/+page.server.ts +++ b/src/routes/groceries/+page.server.ts @@ -2,11 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit'; import { loadUnchecked, loadAll, - toggle, addOrUncheck, } from '$lib/server/services/groceries'; +import { toggleGroceryAction } from '$lib/server/services/grocery-actions'; import { - numericId, parseFormString, parseOptionalNumber, } from '$lib/server/services/validation'; @@ -45,16 +44,7 @@ export const actions: Actions = { return { success: true, items }; }, - toggle: async ({ request }) => { - const data = await request.formData(); - try { - const id = numericId(data.get('id')); - await toggle(id); - } catch { - return fail(400, { error: 'ID invalide' }); - } - return { success: true }; - }, + toggle: ({ request }) => toggleGroceryAction(request), addAndEdit: async ({ request }) => { const data = await request.formData(); diff --git a/src/routes/groceries/+page.svelte b/src/routes/groceries/+page.svelte index 4471738..a159209 100644 --- a/src/routes/groceries/+page.svelte +++ b/src/routes/groceries/+page.svelte @@ -3,6 +3,8 @@ import { goto } from '$app/navigation'; import { enhance } from '$app/forms'; import FuzzyAutocomplete from '$lib/components/FuzzyAutocomplete.svelte'; + import BestRef from '$lib/components/BestRef.svelte'; + import { groupByCategory } from '$lib/shared/group-by-category'; import type { EnrichedGrocery } from '$lib/server/services/groceries'; let { data, form } = $props(); @@ -15,21 +17,6 @@ localItems = data?.items ?? []; }); - function groupByCategory(items: typeof localItems) { - const groups: Record<string, typeof localItems> = {}; - for (const item of items) { - const key = item.categoryName ?? 'Non catégorisé'; - (groups[key] ??= []).push(item); - } - const result: [string, typeof localItems][] = data.categories - .filter((cat) => groups[cat.name]?.length > 0) - .map((cat) => [cat.name, groups[cat.name]]); - if (groups['Non catégorisé']?.length > 0) { - result.unshift(['Non catégorisé', groups['Non catégorisé']]); - } - return result; - } - async function handleAdd(redirectToEdit: boolean) { if (!name.trim()) { addError = 'Le nom est requis'; @@ -105,7 +92,7 @@ <p class="empty">Rien à acheter</p> {:else} <form method="POST" action="?/toggle" use:enhance> - {#each groupByCategory(localItems) as [cat, items] (cat)} + {#each groupByCategory(localItems, data.categories) as [cat, items] (cat)} <h3 class="category-name">{cat}</h3> <ul class="item-list"> {#each items as item (item.id)} @@ -120,16 +107,7 @@ <span class="item-text"> {item.name} {#if item.bestRef} - <span class="best-ref" - >{item.bestRef.reference} - {#if item.bestRef.price && item.bestRef.unit} - {item.bestRef.price.toFixed(2)} - {item.bestRef.unit} - {/if} - {#if item.bestRef.qualityScore != null} - ({item.bestRef.qualityScore}/100) - {/if} - </span> + <BestRef ref={item.bestRef} /> {/if} </span> </button> diff --git a/src/routes/groceries/catalog/+page.server.ts b/src/routes/groceries/catalog/+page.server.ts index 22464dc..fd71e30 100644 --- a/src/routes/groceries/catalog/+page.server.ts +++ b/src/routes/groceries/catalog/+page.server.ts @@ -1,9 +1,9 @@ import { fail } from '@sveltejs/kit'; import { loadAll, - toggle, bulkUpdateGroceryCategory, } from '$lib/server/services/groceries'; +import { toggleGroceryAction } from '$lib/server/services/grocery-actions'; import { numericId, parseOptionalNumber, @@ -22,16 +22,7 @@ export const load: PageServerLoad = async () => { }; export const actions: Actions = { - toggle: async ({ request }) => { - const data = await request.formData(); - try { - const id = numericId(data.get('id')); - await toggle(id); - } catch { - return fail(400, { error: 'ID invalide' }); - } - return { success: true }; - }, + toggle: ({ request }) => toggleGroceryAction(request), bulkUpdateCategory: async ({ request }) => { const data = await request.formData(); diff --git a/src/routes/groceries/catalog/+page.svelte b/src/routes/groceries/catalog/+page.svelte index 4cbcc27..74a82e8 100644 --- a/src/routes/groceries/catalog/+page.svelte +++ b/src/routes/groceries/catalog/+page.svelte @@ -1,6 +1,8 @@ <script lang="ts"> import { enhance } from '$app/forms'; import { SvelteSet } from 'svelte/reactivity'; + import BestRef from '$lib/components/BestRef.svelte'; + import { groupByCategory } from '$lib/shared/group-by-category'; let { data, form } = $props(); @@ -20,24 +22,6 @@ if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id); } - - function groupByCategory(items: typeof catalogItems) { - const groups: Record<string, typeof catalogItems> = {}; - for (const item of items) { - const key = item.categoryName ?? 'Non catégorisé'; - (groups[key] ??= []).push(item); - } - const result: [string, typeof catalogItems][] = []; - for (const cat of data.categories) { - if (groups[cat.name]?.length > 0) { - result.push([cat.name, groups[cat.name]]); - } - } - if (groups['Non catégorisé']?.length > 0) { - result.unshift(['Non catégorisé', groups['Non catégorisé']]); - } - return result; - } </script> {#if catalogItems.length > 0} @@ -91,7 +75,7 @@ <p class="empty">Aucun résultat</p> {:else} <form method="POST" action="?/toggle" use:enhance> - {#each groupByCategory(filteredItems) as [cat, items] (cat)} + {#each groupByCategory(filteredItems, data.categories) as [cat, items] (cat)} <h3 class="category-name">{cat}</h3> <ul class="item-list"> {#each items as item (item.id)} @@ -112,16 +96,7 @@ <span class="item-text"> <span class:checked={item.checked}>{item.name}</span> {#if item.bestRef} - <span class="best-ref" - >{item.bestRef.reference} - {#if item.bestRef.price && item.bestRef.unit} - {item.bestRef.price.toFixed(2)} - {item.bestRef.unit} - {/if} - {#if item.bestRef.qualityScore != null} - ({item.bestRef.qualityScore}/100) - {/if} - </span> + <BestRef ref={item.bestRef} /> {/if} </span> </button> |
