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:
@@ -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[];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user