feat(search): sort=viewed in listAllRecipesPaginated

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 14:17:17 +02:00
parent 5357c9787b
commit 226ca5e5ed
2 changed files with 71 additions and 4 deletions

View File

@@ -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<AllRecipesSort, string> = {
const orderBy: Record<Exclude<AllRecipesSort, 'viewed'>, 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[];

View File

@@ -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<typeof openInMemoryForTest>, 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']);
});
});