From 226ca5e5edc8be1e62c0f6c37cfdee4cbb2597be Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:17:17 +0200 Subject: [PATCH] feat(search): sort=viewed in listAllRecipesPaginated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Sort 'viewed' macht LEFT JOIN gegen recipe_view, ordert nach last_viewed_at DESC mit alphabetischem Tiebreaker. NULL-Recipes (nie angesehen) landen alphabetisch sortiert hinter den angesehenen (CASE-NULL-last statt SQLite 3.30+ NULLS LAST). Ohne profileId faellt der Sort auf alphabetisch zurueck — Sort-Chip bleibt klickbar, ergibt aber sinnvolles Default-Verhalten ohne aktiviertes Profil. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/recipes/search-local.ts | 36 +++++++++++++++++++++--- tests/integration/recipe-views.test.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index 6e8e832..d244f5a 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -88,18 +88,44 @@ export function listAllRecipes(db: Database.Database): SearchHit[] { .all() as SearchHit[]; } -export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created'; +export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed'; export function listAllRecipesPaginated( db: Database.Database, sort: AllRecipesSort, limit: number, - offset: number + offset: number, + profileId: number | null = null ): SearchHit[] { + // 'viewed' branch needs a JOIN against recipe_view — diverges from the + // simpler ORDER-BY-only path. We keep it in a separate prepare for + // clarity. Without profileId, fall back to alphabetical so the + // sort-chip still produces a sensible list. + if (sort === 'viewed' && profileId !== null) { + return db + .prepare( + `SELECT r.id, + r.title, + r.description, + r.image_path, + r.source_domain, + (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars, + (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at + FROM recipe r + LEFT JOIN recipe_view v + ON v.recipe_id = r.id AND v.profile_id = ? + ORDER BY CASE WHEN v.last_viewed_at IS NULL THEN 1 ELSE 0 END, + v.last_viewed_at DESC, + r.title COLLATE NOCASE ASC + LIMIT ? OFFSET ?` + ) + .all(profileId, limit, offset) as SearchHit[]; + } + // NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST // zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und // CASE ist überall zuverlässig. - const orderBy: Record = { + const orderBy: Record, string> = { name: 'r.title COLLATE NOCASE ASC', rating: 'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' + @@ -109,6 +135,8 @@ export function listAllRecipesPaginated( '(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC', created: 'r.created_at DESC, r.id DESC' }; + // Without profile, 'viewed' degrades to alphabetical. + const effectiveSort = sort === 'viewed' ? 'name' : sort; return db .prepare( `SELECT r.id, @@ -119,7 +147,7 @@ export function listAllRecipesPaginated( (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars, (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at FROM recipe r - ORDER BY ${orderBy[sort]} + ORDER BY ${orderBy[effectiveSort]} LIMIT ? OFFSET ?` ) .all(limit, offset) as SearchHit[]; diff --git a/tests/integration/recipe-views.test.ts b/tests/integration/recipe-views.test.ts index 934608f..40c4f72 100644 --- a/tests/integration/recipe-views.test.ts +++ b/tests/integration/recipe-views.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { openInMemoryForTest } from '../../src/lib/server/db'; import { recordView, listViews } from '../../src/lib/server/recipes/views'; import { createProfile } from '../../src/lib/server/profiles/repository'; +import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local'; function seedRecipe(db: ReturnType, title: string): number { const r = db @@ -82,3 +83,41 @@ describe('recordView', () => { expect(() => recordView(db, profile.id, 999)).toThrow(); }); }); + +describe("listAllRecipesPaginated sort='viewed'", () => { + it('puts recently-viewed recipes first, NULLs alphabetically last', async () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + const recipeA = seedRecipe(db, 'Apfelkuchen'); + const recipeB = seedRecipe(db, 'Brokkoli'); + const recipeC = seedRecipe(db, 'Couscous'); + + // View order: B then A. C never viewed. + recordView(db, profile.id, recipeB); + await new Promise((r) => setTimeout(r, 1100)); + recordView(db, profile.id, recipeA); + + const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, profile.id); + expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeC]); + }); + + it('falls back to alphabetical when profileId is null', () => { + const db = openInMemoryForTest(); + seedRecipe(db, 'Couscous'); + seedRecipe(db, 'Apfelkuchen'); + seedRecipe(db, 'Brokkoli'); + + const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, null); + expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']); + }); + + it('keeps existing sorts working unchanged', () => { + const db = openInMemoryForTest(); + seedRecipe(db, 'Couscous'); + seedRecipe(db, 'Apfelkuchen'); + seedRecipe(db, 'Brokkoli'); + + const hits = listAllRecipesPaginated(db, 'name', 50, 0); + expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']); + }); +});