aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-07 19:17:14 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-07 19:17:14 +0200
commit66939bab93cdcb9b7732c45d7bc3b99b6951fe15 (patch)
treeec6bc1631464410f8735da49e02e99abff0f3863 /src
parent6e5dcdb53cea4c3f082d01c937fe5fc1d580c45b (diff)
feat: promote a reference as the primary shown in lists
Add an is_primary flag to references; the promoted ref sorts first on the item page and is the one shown under the item in the list and catalog. Item page gets a star toggle per reference.
Diffstat (limited to 'src')
-rw-r--r--src/lib/server/db/schema.ts4
-rw-r--r--src/lib/server/services/groceries.test.ts24
-rw-r--r--src/lib/server/services/groceries.ts7
-rw-r--r--src/lib/server/services/grocery-actions.ts16
-rw-r--r--src/lib/server/services/references.test.ts35
-rw-r--r--src/lib/server/services/references.ts34
-rw-r--r--src/routes/groceries/categories/+page.server.ts4
-rw-r--r--src/routes/grocery/[id]/+page.server.ts7
-rw-r--r--src/routes/grocery/[id]/+page.svelte37
9 files changed, 150 insertions, 18 deletions
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index b6404ce..5eb6c15 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -32,6 +32,10 @@ export const references = sqliteTable('references', {
price: real('price'),
unit: text('unit'),
qualityScore: integer('quality_score'),
+ // Promoted ref shown first in lists; at most one per grocery.
+ isPrimary: integer('is_primary', { mode: 'boolean' })
+ .notNull()
+ .default(false),
});
export const projects = sqliteTable('projects', {
diff --git a/src/lib/server/services/groceries.test.ts b/src/lib/server/services/groceries.test.ts
index d12111f..dbf46b7 100644
--- a/src/lib/server/services/groceries.test.ts
+++ b/src/lib/server/services/groceries.test.ts
@@ -77,6 +77,30 @@ describe('groceries service', () => {
expect(result.items[0].bestRef?.reference).toBe('Brand A');
});
+ it('bestRef prefers the promoted ref over a higher score', async () => {
+ const now = Temporal.Now.instant().toString({
+ smallestUnit: 'millisecond',
+ });
+ const [g] = await db
+ .insert(groceries)
+ .values({ name: 'coffee', checked: false, createdAt: now })
+ .returning();
+ const [, b] = await db
+ .insert(references)
+ .values([
+ { groceryId: g.id, reference: 'Brand A', qualityScore: 80 },
+ { groceryId: g.id, reference: 'Brand B', qualityScore: 20 },
+ ])
+ .returning();
+ await db
+ .update(references)
+ .set({ isPrimary: true })
+ .where(eq(references.id, b.id));
+
+ const result = await loadAll(db);
+ expect(result.items[0].bestRef?.reference).toBe('Brand B');
+ });
+
it('toggle flips checked state', async () => {
const now = Temporal.Now.instant().toString({
smallestUnit: 'millisecond',
diff --git a/src/lib/server/services/groceries.ts b/src/lib/server/services/groceries.ts
index fc66300..c56599c 100644
--- a/src/lib/server/services/groceries.ts
+++ b/src/lib/server/services/groceries.ts
@@ -1,6 +1,7 @@
import { db as defaultDb } from '$lib/server/db';
import { groceries, categories, references } from '$lib/server/db/schema';
-import { eq, desc, inArray, asc } from 'drizzle-orm';
+import { eq, inArray, asc, desc } from 'drizzle-orm';
+import { referenceOrder } from './references';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '$lib/server/db/schema';
import type { Category, Grocery, Reference } from './types';
@@ -28,7 +29,7 @@ export async function loadUnchecked(
db
.select()
.from(references)
- .orderBy(desc(references.qualityScore), asc(references.id)),
+ .orderBy(...referenceOrder),
]);
return {
@@ -52,7 +53,7 @@ export async function loadAll(
db
.select()
.from(references)
- .orderBy(desc(references.qualityScore), asc(references.id)),
+ .orderBy(...referenceOrder),
]);
return {
diff --git a/src/lib/server/services/grocery-actions.ts b/src/lib/server/services/grocery-actions.ts
index 2138f2d..1cfb05d 100644
--- a/src/lib/server/services/grocery-actions.ts
+++ b/src/lib/server/services/grocery-actions.ts
@@ -14,18 +14,18 @@ export async function toggleGroceryAction(request: Request) {
return { success: true };
}
-export type DeleteRowResult =
- ActionFailure<{ error: string }> | { success: boolean; type?: 'delete' };
+export type RowActionResult =
+ ActionFailure<{ error: string }> | { success: boolean; type?: string };
-/** Shared 'delete by id form field' action for category and reference lists. */
-export async function deleteByIdAction(
+/** Shared 'run fn with the id form field' action for row-based lists. */
+export async function idAction(
request: Request,
- deleteFn: (id: number) => Promise<unknown>,
- extra?: { type: 'delete' },
-): Promise<DeleteRowResult> {
+ fn: (id: number) => Promise<unknown>,
+ extra?: { type: string },
+): Promise<RowActionResult> {
const data = await request.formData();
try {
- await deleteFn(numericId(data.get('id')));
+ await fn(numericId(data.get('id')));
} catch {
return fail(400, { error: 'ID invalide' });
}
diff --git a/src/lib/server/services/references.test.ts b/src/lib/server/services/references.test.ts
index bcefbd0..fa3fc97 100644
--- a/src/lib/server/services/references.test.ts
+++ b/src/lib/server/services/references.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { groceries, references } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { createTestDb, type TestDb } from '$lib/server/db/test-helper';
-import { loadForGrocery, add, deleteReference } from './references';
+import { loadForGrocery, add, promote, deleteReference } from './references';
describe('references service', () => {
let db: TestDb['db'];
@@ -73,6 +73,39 @@ describe('references service', () => {
expect(rows).toHaveLength(1);
});
+ it('promote puts the ref first and clears any sibling primary', async () => {
+ const now = Temporal.Now.instant().toString({
+ smallestUnit: 'millisecond',
+ });
+ const [g] = await db
+ .insert(groceries)
+ .values({ name: 'coffee', checked: false, createdAt: now })
+ .returning();
+ const [a] = await db
+ .insert(references)
+ .values({ groceryId: g.id, reference: 'A', qualityScore: 90 })
+ .returning();
+ const [b] = await db
+ .insert(references)
+ .values({ groceryId: g.id, reference: 'B' })
+ .returning();
+
+ await promote(b.id, db);
+ let refs = await loadForGrocery(g.id, db);
+ expect(refs.map((r) => r.reference)).toEqual(['B', 'A']);
+
+ await promote(a.id, db);
+ refs = await loadForGrocery(g.id, db);
+ expect(refs.map((r) => r.reference)).toEqual(['A', 'B']);
+
+ const rows = await db
+ .select()
+ .from(references)
+ .where(eq(references.groceryId, g.id));
+ expect(rows.filter((r) => r.isPrimary)).toHaveLength(1);
+ expect(rows.find((r) => r.id === a.id)?.isPrimary).toBe(true);
+ });
+
it('deleteReference removes the ref', async () => {
const now = Temporal.Now.instant().toString({
smallestUnit: 'millisecond',
diff --git a/src/lib/server/services/references.ts b/src/lib/server/services/references.ts
index c5b6630..d48fb9b 100644
--- a/src/lib/server/services/references.ts
+++ b/src/lib/server/services/references.ts
@@ -1,9 +1,16 @@
import { db as defaultDb } from '$lib/server/db';
import { references } from '$lib/server/db/schema';
-import { eq, desc, asc } from 'drizzle-orm';
+import { eq, ne, and, desc, asc } from 'drizzle-orm';
import type { AppDatabase } from './groceries';
import type { Reference } from './types';
+/** Primary refs first, then best score, then insertion order. */
+export const referenceOrder = [
+ desc(references.isPrimary),
+ desc(references.qualityScore),
+ asc(references.id),
+];
+
export async function loadForGrocery(
groceryId: number,
db = defaultDb,
@@ -12,7 +19,30 @@ export async function loadForGrocery(
.select()
.from(references)
.where(eq(references.groceryId, groceryId))
- .orderBy(desc(references.qualityScore), asc(references.id));
+ .orderBy(...referenceOrder);
+}
+
+/**
+ * Marks `id` as the primary ref of its grocery, clearing any sibling primary
+ * so at most one ref per grocery is promoted.
+ */
+export async function promote(id: number, db = defaultDb): Promise<void> {
+ const [target] = await db
+ .select()
+ .from(references)
+ .where(eq(references.id, id));
+ if (!target) throw new Error('Reference not found');
+
+ await db
+ .update(references)
+ .set({ isPrimary: true })
+ .where(eq(references.id, id));
+ await db
+ .update(references)
+ .set({ isPrimary: false })
+ .where(
+ and(eq(references.groceryId, target.groceryId), ne(references.id, id)),
+ );
}
export async function add(
diff --git a/src/routes/groceries/categories/+page.server.ts b/src/routes/groceries/categories/+page.server.ts
index d02b328..6e0e3b7 100644
--- a/src/routes/groceries/categories/+page.server.ts
+++ b/src/routes/groceries/categories/+page.server.ts
@@ -6,7 +6,7 @@ import {
} from '$lib/server/services/categories';
import {
addCategoryAction,
- deleteByIdAction,
+ idAction,
} from '$lib/server/services/grocery-actions';
import { numericId } from '$lib/server/services/validation';
import type { Category } from '$lib/server/services/types';
@@ -23,7 +23,7 @@ export const load: PageServerLoad = async () => {
export const actions: Actions = {
add: ({ request }) => addCategoryAction(request),
- delete: ({ request }) => deleteByIdAction(request, deleteCategory),
+ delete: ({ request }) => idAction(request, deleteCategory),
reorder: async ({ request }) => {
const data = await request.formData();
diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts
index 656e19c..a4ad377 100644
--- a/src/routes/grocery/[id]/+page.server.ts
+++ b/src/routes/grocery/[id]/+page.server.ts
@@ -6,10 +6,11 @@ import { loadAll as loadCategories } from '$lib/server/services/categories';
import {
loadForGrocery,
add as addRef,
+ promote,
deleteReference,
} from '$lib/server/services/references';
import { updateGrocery, deleteGrocery } from '$lib/server/services/groceries';
-import { deleteByIdAction } from '$lib/server/services/grocery-actions';
+import { idAction } from '$lib/server/services/grocery-actions';
import {
numericId,
parseFormString,
@@ -70,7 +71,9 @@ export const actions: Actions = {
},
delete: ({ request }) =>
- deleteByIdAction(request, deleteReference, { type: 'delete' }),
+ idAction(request, deleteReference, { type: 'delete' }),
+
+ promote: ({ request }) => idAction(request, promote, { type: 'promote' }),
update: async ({ request, params }) => {
const data = await request.formData();
diff --git a/src/routes/grocery/[id]/+page.svelte b/src/routes/grocery/[id]/+page.svelte
index 027c079..1a9aac8 100644
--- a/src/routes/grocery/[id]/+page.svelte
+++ b/src/routes/grocery/[id]/+page.svelte
@@ -156,6 +156,9 @@
{#each data?.references ?? [] as ref (ref.id)}
<Row variant="ref">
<div class="ref-info">
+ {#if ref.isPrimary}
+ <span class="primary-badge" title="Référence principale">★</span>
+ {/if}
<strong>{ref.reference}</strong>
{#if ref.price}
<span class="ref-detail"
@@ -166,6 +169,16 @@
<span class="ref-detail">Note : {ref.qualityScore}/100</span>
{/if}
</div>
+ {#if !ref.isPrimary}
+ <button
+ type="submit"
+ class="promote-btn"
+ name="id"
+ value={ref.id}
+ formaction="?/promote"
+ aria-label="Définir comme référence principale">☆</button
+ >
+ {/if}
<Button variant="ghost" name="id" value={ref.id} ariaLabel="Supprimer"
>&times;</Button
>
@@ -226,6 +239,30 @@
font-weight: 500;
}
+ .primary-badge {
+ color: var(--accent);
+ font-size: 0.875rem;
+ }
+
+ .promote-btn {
+ flex-shrink: 0;
+ width: 2rem;
+ aspect-ratio: 1;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 1.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .promote-btn:hover {
+ color: var(--accent);
+ }
+
.error {
margin-top: -0.25rem;
margin-bottom: 0.5rem;