E01-S05 — One search: results page with scope (All / Mine / Favorites)
Issue: #208 · Epic: #201 · Backlog:
.ai/specs/backlog-przebudowa.md(E01-S05, refs C2/A3) · Depends on: S01 (tokens/primitives), S02 (public access +public_recipes), S04 (home search field +searchPath()seam), S06 (favorites table), S11 (getFullAccess). All live on master.
📝 TLDR
Today a search lands on the owner-only /recipes?q=. A guest who searches from the home, or taps a “Często szukane” pill, is bounced to /login. A signed-in user only ever searches their own list, and the search filters in JS over every recipe they have, with the full ingredients JSON, on each keystroke. Proposed: one public results page, /szukaj?q=. It shows the field with ”× Wyczyść”, the count “N przepisów dla „q””, row cards (96 px thumbnail, 20 px title, “Kategoria · czas”, source, favorites bar) and “Pokaż kolejne 14”. A signed-in user also gets a “Szukaj w:” segment: Wszystkie / Moje / Ulubione. An empty result offers “Szukaj „q” w internecie”. Matching moves into the database: one search_recipes RPC over title and ingredient names, with a pg_trgm GIN index. The home’s searchPath() seam is repointed at /szukaj, and /recipes?q= redirects there with the Mine scope, so there is one search.
📝 Problem Statement
- Guests cannot search. The epic promises that a guest browses and searches (“gość przegląda i szuka”). S04 shipped the home search field against an interim target, assumption A1, which leaves that promise broken.
- There is no way to search everyone’s recipes, or only the favorites.
/recipesis “Moje przepisy” only. - The search does not scale.
/recipes?q=selects every row of the user withingredientsjsonb and filters in JS (matchesQuery). The debounced field pushes a navigation every 300 ms while typing. The #225 performance review asked for this to move into the database (comment on #208). - Readability. The current field is 40 px tall with a 14 px grey placeholder and a 16 px unlabelled ”×” icon, which breaks the E01 rules (48 px targets, labelled icons, 19 px body text).
📝 Proposed Solution
- A new public route
/szukaj(server component). Its params areq(the query),zakres(wszystkie|moje|ulubione) andstrona(1…10, the “show more” depth). - A new
public.search_recipes(p_query, p_scope, p_limit)RPC (plpgsql,security invoker). It splits the query into words, matches every word against the lower-cased title + ingredient names, ranks title hits first and then newest, and returns the page rows plus atotal_count. Mine and Favorites scope through the caller’s own RLS-visible rows. A guest asking for them gets nothing. - A pg_trgm GIN expression index on
recipes. It is built over the same immutablerecipe_search_text(title, ingredients)function the RPC filters on. - The home search field becomes the search field.
HomeSearchgains an optional current query, a scope (a hidden field) and the ”× Wyczyść” link./szukajand “Moje przepisy” both use it, and the debouncedRecipeSearchis deleted. - Seams.
SEARCH_PATH = '/szukaj'andsearchPath(q, scope?)inroutes.ts./szukajjoinsisPublicRoute.szukajjoinsLIST_KEYS, so the recipe page shows ”← Wyniki wyszukiwania” with prev/next.
Alternatives considered:
- PostgREST filters on
public_recipes(title.ilike) with no migration. Lost: PostgREST cannot ILIKE a jsonb column, so ingredient search would be lost. That is today’s main use (“co z ziemniaków”). It also cannot express “every word matches”. - A stored generated
search_textcolumn onrecipes, exposed on the view. Lost: it needs a view change (S07 just appended columns, and S16 may too) plus a backfill, for no gain over an expression index. The RPC also keeps the count, the scope and the paging in one round trip. - Full-text search (
tsvector,polishconfig). Lost: Supabase ships no Polish dictionary/stemmer, andsimpleFTS loses the substring matches users rely on (“pomidor” → “pomidorowa”). Trigram ILIKE keeps today’s substring semantics. - Infinite scroll. Rejected by the AC (“bez infinite scroll”). Older users lose their place, and the footer becomes unreachable.
📝 Architecture
- Route:
src/app/szukaj/page.tsx(server) +src/app/szukaj/layout.tsx(rendersAppHeaderlike/przepisy) +error.tsx(the sharedRouteErrorFallback). It readsgetCurrentUser(), parses the params withparseSearchParams(pure), callssearchRecipes(), and for a signed-in user loads which result ids are favorites (onefavorites … in (ids)query). - New lib:
src/lib/search.ts, pure and unit-tested:parseSearchParams(raw, isAuthenticated)→{ q, scope, page }. It trims and caps the query at 100 chars; a guest is alwaysall;stronais clamped to 1…10.SEARCH_PAGE_SIZE = 14searchResultsLabel(n, q)→ “1 przepis dla „q”” / “3 przepisy …” / “5 przepisów …”, reusingrecipesNounsearchRecipes(supabase, { q, scope, page })→{ rows, total } | { error }, which calls the RPC withp_limit = 14 * pagewebSearchUrl(q)
- Components (
src/app/components/search/):search-scope.tsx: the “Szukaj w:” segment. Three<Link>s styled as a segmented control,aria-current="page"on the active one, activebg-[#111827]with white text, the resttext-inkwith a 2 px border. A guest does not get it.search-results.tsx: the count (role="status"), a<ol>of row cards and the “Pokaż kolejne 14” link.card-favorite-bar.tsx(client): the favorites bar under each card. A signed-in user gets an optimistic toggle via the existingaddFavorite/removeFavorite. A guest gets a link tologinPath(favoriteReturnPath(slug)), the S06 return flow.web-search-fallback.tsx(client): the empty state. “Nie ma tego, czego szukasz?” plus a button “Szukaj „q” w internecie”. A user with full access gets the existingsearchViaExaandExaResultsPanel, the latter exported fromadd-recipe-form.tsxand unchanged, so the “Zapisz” action keeps working. Anyone else gets a link to a regular web search (A4).
- Reused:
RecipeCardgainsvariant: 'tile' | 'row'(defaulttile, so existing callers are unchanged) and an optionalmetaline. Therowvariant puts the 96 px thumbnail on the left and the 20 px title, “Kategoria · czas” and source on the right. The favorites bar sits outside the link, because a button cannot nest in an anchor.RecipeListSnapshot(listKey="szukaj"),recipesNoun,formatMinutes,getFullAccess,HomeSearch.
- Changed seams:
routes.ts:SEARCH_PATH = '/szukaj';searchPath(q, scope?)omitszakresforall;isPublicRoute('/szukaj').recipe-list-context.ts:LIST_KEYS+='szukaj'.
/recipes:?q=→redirect(searchPath(q, 'moje')). This keeps bookmarks working and makes it one search.matchesQueryand the jsonb select branch are removed.- Its field becomes
HomeSearchwith scopemoje.
- Home: no edit beyond the seam.
HomeSearchalready submits toSEARCH_PATH, and the popular pills already callsearchPath(term).
Takeaway: one route, one RPC, and every entry point reaches it through the searchPath() seam. The card and the favorite actions are reused, not forked.
📝 Data Model
Migration supabase/migrations/20261017160000_search_recipes.sql. It is additive and idempotent, and owner-applied (not applied by this story).
public.recipe_search_text(p_title text, p_ingredients jsonb) returns text,language sql immutable parallel safe. It returnslower(title || ' ' || ingredient names joined by ' '). Only array elements that are objects with a stringnamecount, so a non-array or malformedingredientscontributes''.create index if not exists recipes_search_text_trgm_idx on public.recipes using gin (public.recipe_search_text(title, ingredients) gin_trgm_ops).pg_trgmis already enabled (init migration).public.search_recipes(p_query text, p_scope text default 'all', p_limit int default 14):- Returns
table(id, slug, title, image_url, category, description, source_type, source_url, source_author, total_time_minutes, prep_time_minutes, cook_time_minutes, created_at, total_count bigint). language plpgsql stable security invoker set search_path = ''.- Words: the query is lower-cased and split on whitespace, keeping up to 5 words of up to 50 chars each.
\,%and_are escaped, and each word becomes%word%. An empty query returns no rows. - Scope: the scope is validated (
all|mine|fav, else an exception,22023).allreadspublic.public_recipes.mineandfavreturn nothing whenpublic.current_user_id()is null.mineaddsexists (recipes r where r.id = v.id and r.user_id = current_user_id()). Recipes RLS is owner-only, so this also holds by RLS.favaddsexists (favorites f where f.recipe_id = v.id and f.user_id = current_user_id()).- Each scope is its own
return querystatement, soanonnever touchesfavorites, which it has no grant on. Postgres checks table privileges per executed statement.
- Filter:
recipe_search_text(v.title, v.ingredients) like v_patterns[1] and … like all (v_patterns). The first, indexable predicate lets the planner use the trigram index; the second enforces every word. - Order: title hits first (
lower(title) like all(patterns)), thencreated_at desc, id desc.total_countiscount(*) over (). p_limitis clamped to 1…140, so a hand-editedstronacannot dump the table.
- Returns
- Grants:
revoke all on function … from public; grant execute … to anon, authenticated.recipe_search_textstays executable by default; it is pure. - No view change, no new table, no data change. Rollback:
drop function public.search_recipes(text, text, int); drop index public.recipes_search_text_trgm_idx; drop function public.recipe_search_text(text, jsonb);. - Rehearsal:
.ai/qa/s05-rehearse-search-recipes.sqlrunsbegin; <migration>; <assertions as anon / as a user>; rollback;against production through the Management API. It checks:- anon
allfinds rows - anon
mineandfavreturn 0 rows without error - a user’s
mineonly returns their own rows favonly returns their favorites%and_in the query are literal- an ingredient-only match is found
- the index exists
- anon
📝 API Contracts
- RPC
search_recipes(p_query text, p_scope text, p_limit int)as above. It is called only fromsearchRecipes()insrc/lib/search.ts, andsupabase.types.tsgains its signature (hand-added in the same shapesupabase gen typesemits). - URL contract
/szukaj?q={query}&zakres={wszystkie|moje|ulubione}&strona={n}. All params are optional. An unknownzakresfalls back towszystkie, and an out-of-rangestronais clamped.searchPath(q, scope)is the only builder. It omitszakresforwszystkieandstronafor 1, so links stay short and canonical. - Redirect:
/recipes?q=x[&category&tag]→/szukaj?q=x&zakres=moje(307). The category and tag filters are dropped (A6).
📝 UI/UX
Mobile-first (375 px), inside PageContainer. The copy is Polish, exactly as below. Prototype: .ai/specs/assets/e01-s05-search/mockup.html.
- H1 “Szukaj przepisu” (28 px,
text-ink). It is visually first, so the screen states what it is even before any query. - Field.
HomeSearchwithdefaultValue = q, the label “Szukaj przepisu po nazwie lub składniku”, a 60 px input, the mic and the “Szukaj przepisu” submit. A hiddenzakresinput carries the current scope. With a non-emptyq, a ”× Wyczyść” link sits under the field, right-aligned. It is 48 px tall, underlined,text-ink, has anXicon plus the text, and goes tosearchPath('', scope). The search page keeps the scope, focuses the field (autoFocusonly whenqis empty) and shows no results. - “Szukaj w:” (signed-in only). A 16 px
text-ink-softlabel followed by a three-part segmented control, full width, each segment at least 48 px tall and 18 px bold:- The labels are “Wszystkich”, “Moich” and “Ulubionych”, following the backlog wording; the label reads “Szukaj w: Wszystkich”.
- The active segment is
bg-[#111827] text-whitewitharia-current="page"; the others are whitetext-inkwith a 2 pxborder-border-card-2. - Each segment is a
<Link>tosearchPath(q, scope), which resetsstrona. - A guest does not see it.
- Count. “N przepisów dla „q”” (19 px,
text-ink, the number bold), in arole="status"paragraph so a screen-reader user hears the result after a scope switch. It is hidden whenqis empty. Plurals: 1 przepis / 2–4 przepisy (except 12–14) / 5+ przepisów. - Results. An
<ol>with an 8 px gap between items. Each item is the row card:- The whole top part is one link to
recipePath(slug, { from: 'szukaj' }). - A 96×96 thumbnail,
rounded-field,object-cover,alt=""because the title is right beside it. A missing image gets the accent placeholder. - On the right: the title, 20 px bold
text-ink, line-clamped to 3; “Kategoria · czas”, 16 pxtext-ink-soft, e.g. “Zupy · 45 min”, with the time omitted when unknown; and the source, 16 pxtext-ink-soft, fromsourceLabel(), never a login. - Below, the favorites bar spans the card’s full width, outside the link, with a 1 px
border-border-cardtop:- signed-in, not a favorite: an outline heart + “Dodaj do ulubionych” button, 48 px tall,
text-action-text,aria-pressed="false" - a favorite: a filled heart + “W ulubionych”,
aria-pressed="true" - a guest: a link “Dodaj do ulubionych” → login → back to the recipe with
?fav=1 - an error rolls back and shows “Nie udało się zapisać. Spróbuj ponownie.” (
role="alert")
- signed-in, not a favorite: an outline heart + “Dodaj do ulubionych” button, 48 px tall,
- In the Favorites scope, un-favoriting leaves the card in place, with the button back to “Dodaj do ulubionych”. It does not vanish from under the finger; it drops out on the next search.
- The whole top part is one link to
- “Pokaż kolejne 14”. It is shown when
total > rows.length. It is a full-width outline button link (buttonClasses('secondary','lg'), 56 px) tosearchPath(q, scope, page + 1)with#wynik-{rows.length + 1}, so the browser scrolls to the first new card. Each<li>hasid="wynik-{n}". On the last page it is replaced by nothing; the count already says how many there are. The button reads “Pokaż kolejne {min(14, remaining)}”. Paststrona10 (140 rows) it is not offered (A5). - Empty result. In place of the list:
- H2 “Nie ma tego, czego szukasz?”
- the copy “Żaden przepis nie pasuje do „q”. Sprawdź pisownię albo poszukaj w internecie.”
- a primary button “Szukaj „q” w internecie” (
buttonClasses('primary','lg'), full width, with aGlobeicon and text) - When the user has full access, it runs
searchViaExa(q)in place. The button shows “Szukam…” witharia-busy, then rendersExaResultsPanelwith the existing “Zapisz” action. - Otherwise, a guest or a user without full access gets a link that opens
webSearchUrl(q)(https://www.google.com/search?q=przepis+{q}) in a new tab. It carriesrel="noopener noreferrer"and the visible suffix “(otworzy się w nowej karcie)”.
- No query (
/szukaj): the H1, the field (autofocus), the scope for signed-in users and the hint “Wpisz nazwę dania albo składnik, np. „pierogi” albo „cukinia”.” There is no list and no count. <title>is “„q” — Szukaj · ZapiszPrzepis”, or “Szukaj · ZapiszPrzepis” with no query.robots: noindexkeeps search pages out of indexes.
Accessibility:
- one H1
- the count is a polite live status
- scope links use
aria-current - every icon has text beside it
- focus rings come from
globals.css - at 320 px nothing scrolls horizontally: the segment labels fit at 18 px in thirds of 288 px, wrapping to two lines if text is scaled up, with no fixed heights
- the row card’s title link is the card’s single tab stop; the favorites button is the second
📝 Edge Cases & Failure Scenarios
- A guest opens
?zakres=mojeorulubione(a shared link): this is treated aswszystkie, and no segment is shown. There is no login bounce, because the page is public. qis only punctuation or wildcards (%,_,\): these are escaped and matched literally, so the result is usually empty. A query that is whitespace only counts as empty.- A very long
q: it is cut to 100 chars inparseSearchParams; the RPC keeps 5 words of up to 50 chars each. - The RPC errors (migration not applied yet, or the DB is down): the error is logged, and the page shows “Wyszukiwanie chwilowo nie działa. Spróbuj za chwilę.” (
role="alert") under the field. There is no 500 and no web-search fallback. The migration must be applied before the implementation deploys (it is additive, so applying it early is safe). - A favorite toggle while signed out in another tab: the action returns
unauthenticated, and the bar rolls back with the error. - Scope switch with a stale
strona: the segment links dropstrona, so the user returns to page 1. - The Exa fallback without full access:
searchViaExareturnsno_access. This is not reachable from the UI (the link variant is rendered instead), but the panel shows the generic error if it happens. - A recipe deleted between search and click: the recipe page 404s as today.
- A++ text scale (1.3×): the row card’s text column wraps, and the thumbnail stays 96 px with
shrink-0.
📝 Risks & Impact Review
- Deploy order (medium). The page depends on an unapplied RPC. Applying the migration first is safe, because nothing else references the new objects. Deploying first would show the “chwilowo nie działa” alert on every search. Mitigation: the PR body and hand-off state the migration-first order. The rehearsal proves the migration applies cleanly on production data, rolled back.
- The “Moje przepisy” search loses category/tag + query combinations (A6). Mitigation: the scope segment and the Mine results cover the common case. S16 owns the richer category listing.
- Index cost: one GIN trigram index over a few hundred rows is negligible on write.
recipe_search_textis immutable, so a future change to the ingredient shape needs a reindex (noted in the function comment). - Diacritics: “zurek” does not find “żurek”.
unaccentis not immutable, so it cannot back the index without a wrapper, and it is out of scope here (Non-goal; follow-up if users ask). - S06/S16 overlap: S06 owns favorites semantics, and this story only reuses its actions. S16 (category tags) touches
/przepisyand possiblyrecipe-card.tsx. Thevariantprop is additive, so callers merge cleanly. - Security: the RPC is
security invoker, so the caller’s RLS applies. Theallscope reads the same guest-safe view the home already reads, and no new column is exposed.
📋 Non-goals
- Search ranking beyond “title hit first, then newest” (no trigram similarity score, no typo tolerance).
- Diacritic-insensitive matching (
unaccent). - Category or tag filters on
/szukaj(S16). - Search analytics, or data-driven “Często szukane” (S04 A6 stays curated).
- Desktop layout beyond a sane
max-w(S17).
📋 Phasing
- Phase 1: database. The migration, the rolled-back rehearsal and the types. Nothing user-visible.
- Phase 2: search lib + seams.
src/lib/search.ts,routes.tsandLIST_KEYS, with unit tests. - Phase 3: the
/szukajpage. The route, the scope, the results, the row card, the favorites bar and the web fallback. Verified on the dev server. - Phase 4: one search.
/recipes?q=redirect,HomeSearchon “Moje przepisy”,RecipeSearchremoved.
📋 Implementation Plan
The unit runner is node-only (no DOM), so components are verified on the dev server. Their logic lives in pure helpers, which are unit-tested.
Phase 1 — database
supabase/migrations/20261017160000_search_recipes.sqlas in Data Model, with a header comment covering rollback and ordering: independent of S11’s20261017140000and S07’s20261017150000, and it touches no view..ai/qa/s05-rehearse-search-recipes.sqlis the rolled-back rehearsal. Run it against production via the Management API (begin … rollback), never applied. Record the results in the PR.src/lib/supabase.types.ts: addsearch_recipesunderFunctions.
Phase 2 — lib and seams
src/lib/routes.ts:SEARCH_PATH = '/szukaj'SearchScope = 'wszystkie' | 'moje' | 'ulubione'searchPath(q, scope = 'wszystkie', page = 1)isPublicRoutecovers/szukaj- Test:
routes.test.tscovers encoding, the default scope omitted, page 1 omitted and/szukajpublic.
src/lib/recipe-list-context.ts: add'szukaj'toLIST_KEYS. Test:parseListKey('szukaj').src/lib/search.ts:parseSearchParams,SEARCH_PAGE_SIZE,searchResultsLabel,webSearchUrl,toRpcScopesearchRecipes(supabase, …), which maps the RPC rows andtotal_countand returns{ error }on failure- Test:
src/lib/search.test.ts, with a mockedrpc, covering the guest scope forced to all, clamping, plurals (1/2/5/12/22) and the error path.
Phase 3 — /szukaj
src/app/components/recipe-card.tsx: addvariantandmeta, withtileunchanged. Verify/,/recipesand/ulubioneare unchanged.src/app/components/search/search-scope.tsx,search-results.tsx,card-favorite-bar.tsxandweb-search-fallback.tsx. ExportExaResultsPanelfromadd-recipe-form.tsx, otherwise unchanged.src/app/components/home/home-search.tsx: optionaldefaultValue,scope(a hidden input when notwszystkie),autoFocus, and the ”× Wyczyść” link whendefaultValueis set. The home call sites are unchanged.src/app/szukaj/{page,layout,error}.tsx. Verify on the dev server at 375 px and 320 px:- guest: results, empty → Google link,
?zakres=mojeignored - signed-in: scope switch, favorite toggle, “Pokaż kolejne 14”
pnpm typecheck && pnpm lint && pnpm test
- guest: results, empty → Google link,
Phase 4 — one search
src/app/(authenticated)/recipes/page.tsx:q→redirect(searchPath(q, 'moje')); removematchesQueryand the jsonb branch.recipes-content.tsx: useHomeSearchwithscope="moje"in place ofRecipeSearch, and drop the search-specific empty copy. Deletesrc/app/components/recipe-search.tsx.- Grep for leftovers:
'/recipes?q',RecipeSearch,matchesQuery. Final gate plus a dev-server pass across/,/szukajand/recipes.
Resolved assumptions (autonomous defaults)
| # | Question | Default chosen | Rationale |
|---|---|---|---|
| A1 | How does matching work? | A trigram ILIKE over the lower-cased title + ingredient names; every word must match; title hits are ranked first, then newest. | It keeps today’s substring semantics (matchesQuery) and moves them into the DB as the issue asks. FTS has no Polish stemmer on Supabase. |
| A2 | What does “Moje” mean? | Recipes whose user_id is me, i.e. those I added first (CLAUDE.md model). | It matches /recipes and the global-dedup model (S03a). |
| A3 | Can a guest open the Mine/Favorites scope by URL? | No: it silently falls back to All, and the segment is hidden. | The AC hides the segment for guests. A public link should never bounce to login. |
| A4 | What is the web fallback for guests and users without full access? Exa is paid and full-access only (S11). | Full access: in-page Exa results with “Zapisz”. Otherwise: a Google search link (przepis {q}) in a new tab. | It reuses the existing Exa path without opening a paid API to guests, and still answers “Szukaj „q” w internecie” for everyone. |
| A5 | How is “Pokaż kolejne 14” paged? | A server-rendered link strona=n+1 that re-renders with limit 14·n and jumps to #wynik-{first new}. It is capped at 10 pages (140). | It works without JS, survives back/refresh, and has no infinite scroll. One cheap query per click. |
| A6 | What happens to search on “Moje przepisy” (/recipes?q=)? | It redirects to /szukaj?q=&zakres=moje, and its field submits there. Category/tag + query combinations are dropped. | “One search” (the issue title). It removes the JS filter flagged in #225. |
| A7 | What do the scope labels say? | “Szukaj w:” + “Wszystkich / Moich / Ulubionych”; URL values wszystkie / moje / ulubione. | The backlog wording; Polish URLs like /szukaj, /przepis. |
| A8 | Is there a favorites bar on result cards, given S06 Q1 put the heart only on the recipe page? | Yes. A compact bar (48 px) reusing addFavorite/removeFavorite, with the guest login-return link. | The S05 AC lists “pasek ulubionych”. S06 Q1 deferred the card bar to the S04/S05 card redesign. |
| A9 | Does the category-filter.tsx named in the issue scope change? | No change. /szukaj has no category filter (Non-goal, S16). | The AC has no category control on the results page. |