Recipe detail page — prep time and additional media (Phase 1) plan implementacji
Przegląd
Detail page przepisu nie pokazuje czasów (przygotowanie / gotowanie / łącznie). Plan dodaje 3 osobne pola typu int minutes (zgodne z schema.org Recipe vocabulary), rozszerza prompt OpenAI o ich ekstrakcję, renderuje czytelny po polsku format („1 godz 30 min”) z ikonami nad sekcją Składników, i refreshuje istniejące przepisy żeby też miały te dane.
Uwaga zakresu: „dodatkowe media” (gallery, YouTube embed) jest poza scope’em tej zmiany. YouTube embed pasuje do S-04 youtube-recipe-source (proposed w roadmap). Gallery wymaga zmian w Firecrawl options i osobnej decyzji o storage — osobny issue.
Analiza stanu obecnego
- Schema
recipesnie ma żadnych pól czasu (supabase/migrations/20260607000000_recipe_schema.sql:33-50). - OpenAI prompt w
src/inngest/functions.ts:62-74nie pyta o czasy.RecipeDatainterface (src/inngest/functions.ts:16-22) nie ma pól czasu. - Detail page renderuje title / image / ingredients / steps (
src/app/(authenticated)/recipes/[slug]/page.tsx:62-148). Nie ma sekcji metadata typu czas / porcje. - Inngest function ma już insert-with-slug-retry + URL-collision branch (z recipe-url-dedup Phase 3) → URL-collision branch obecnie tylko podpina existing recipe do share’a. Można rozszerzyć żeby też wypełniał luki.
- Istniejący 1 przepis (Tortilla z patelni) ma wszystkie 3 pola czasu null po migracji (kolumny nullable).
Pożądany stan końcowy
Po wdrożeniu:
- Każdy nowy przepis wyekstrahowany przez Firecrawl + OpenAI ma
prep_time_minutes,cook_time_minutes,total_time_minutes(każde nullable jeśli AI nie znalazł). - Detail page pokazuje sekcję czasów (ikony + format „1 godz 30 min”) nad/przy Składnikach. Renderuje tylko te pola które nie są null. Jeśli wszystkie 3 null — sekcja całkowicie ukryta.
- Skrypt
scripts/refresh-recipe-times.tsuruchomiony jednorazowo po deployu wypełnia czasy istniejących przepisów (obecnie: 1 przepis). - Inngest URL-collision branch wypełnia null fields istniejącego przepisu danymi z nowej ekstrakcji.
Kluczowe odkrycia
- Standard schema.org Recipe →
prepTime+cookTime+totalTime(3 osobne) ułatwia przyszłe SEO Rich Results.total != prep + cook(passive time np. marynowanie) — uzasadnia 3 osobne pola. - Inngest function ma już sophisticated insert loop — nie chcemy zepsuć URL-collision branch (
src/inngest/functions.tspo Phase 3 recipe-url-dedup). Rozszerzenie tej branch musi zachować obecne zachowanie (link share do existing recipe) i tylko DODAĆ update-gaps. - Lesson centralize env reads in lib/env.ts — wzorzec dla małych helperów (
format-minutes.tsjako pure function).
Czego NIE robimy
- Gallery / multi-image extraction (osobna zmiana, wymaga adjustowania Firecrawl options)
- YouTube embed (część S-04 youtube-recipe-source)
performTimeze schema.org (nowszy field, rzadko używany w praktyce)- Servings / yield (osobny scope)
- Manual edycja czasów w UI (przeciwne PRD §Non-Goals „zero data entry”)
- Schema.org JSON-LD na detail page dla SEO (osobny issue, zależy też od adresowania)
Podejście do implementacji
Trzy fazy następujące po sobie, każda samodzielnie shippable:
- Schema + extraction (Phase 1) — nullable kolumny w DB, AI prompt updated, nowe pola w
RecipeData. Nowe przepisy zaczynają mieć czasy. - Render (Phase 2) — formatter helper, sekcja na detail page. Czasy widoczne.
- Bulk refresh (Phase 3) — Inngest update-gaps semantics + jednorazowy skrypt. Stare przepisy podciągnięte.
Każda faza weryfikowana niezależnie. Phase 2 mogłaby trafić bez Phase 1 (render działa pusto), ale lepiej w tej kolejności.
Faza 1: Schema + extraction
Przegląd
Dodaj 3 nullable kolumny prep_time_minutes, cook_time_minutes, total_time_minutes (int) do tabeli recipes. Zaktualizuj OpenAI prompt w src/inngest/functions.ts żeby zwracał te pola. Zaktualizuj RecipeData interface i mapowanie do insertu.
Wymagane zmiany
1. Schema migration
Plik: supabase/migrations/<TIMESTAMP>_recipe_times.sql (nowy — użyj date -u +%Y%m%d%H%M%S)
Cel: Dodać 3 nullable kolumny do public.recipes. Nie pre-fillować istniejących wierszy (zrobi to Phase 3 przez Inngest).
Kontrakt: alter table public.recipes add column prep_time_minutes int, podobnie cook_time_minutes i total_time_minutes. Każda nullable bez default. Komentarze SQL opisujące że to czas w minutach.
2. OpenAI prompt + RecipeData
Plik: src/inngest/functions.ts
Cel: Rozszerzyć system prompt OpenAI o 3 pola czasu. Rozszerzyć RecipeData interface o prepTimeMinutes?, cookTimeMinutes?, totalTimeMinutes? (number | null). Insert do recipes dodaje te 3 pola.
Kontrakt:
- System prompt JSON structure dodaje:
"prepTimeMinutes": "active prep time in minutes as integer, or null if unknown", "cookTimeMinutes": "...", "totalTimeMinutes": "..." - Rules dodaje: „total_time includes passive time (marinating, resting, cooling); prep+cook may not sum to total”
RecipeDatainterface gets 3 optionalnumber | nullfields- Insert object dodaje
prep_time_minutes: recipeJSON.prepTimeMinutes ?? null(i analogicznie) — explicit null żeby zachować nullable semantics
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - Migration aplikuje się:
pnpm exec supabase db push - ESLint przechodzi (brak nowych warnings na zmienionych plikach):
pnpm exec eslint src/inngest/functions.ts
Weryfikacja ręczna
- Po migracji
select column_name, data_type, is_nullable from information_schema.columns where table_name='recipes' and column_name like '%_time_minutes'pokazuje 3 wiersze, każdyinteger,YES(nullable) - Po deployu: udostępnij nowy URL przepisu z bloga zawierającego czasy (np. mecooks tortilla — sprawdzi przy okazji ile pól AI zwrócił), poczekaj na Inngest run, sprawdź w bazie
select title, prep_time_minutes, cook_time_minutes, total_time_minutes from recipes order by id desc limit 1
Uwaga implementacyjna: Po zakończeniu fazy zatrzymaj się na manual confirmation.
Faza 2: Render
Przegląd
Helper format-minutes.ts zamieniający int na czytelny po polsku ciąg („1 godz 30 min”). Sekcja czasów na detail page z ikonami nad sekcją Składników. Pokaż tylko fields które nie są null.
Wymagane zmiany
1. Pure formatter helper
Plik: src/lib/format-minutes.ts (nowy)
Cel: Wystawić formatMinutes(minutes: number): string jako pure function. Konwersja: < 60 → „X min”, >=60 i % 60 === 0 → „X godz”, inaczej „X godz Y min”.
Kontrakt: formatMinutes(90) === '1 godz 30 min', formatMinutes(45) === '45 min', formatMinutes(120) === '2 godz', formatMinutes(0) === '0 min'. Throw / undefined behavior dla ujemnych — caller nie powinien przekazywać.
2. Times section na detail page
Plik: src/app/(authenticated)/recipes/[slug]/page.tsx
Cel: Dodać sekcję czasów (3 badge’y z ikonami: Clock dla prep, Flame dla cook, Timer dla total — z lucide-react). Pokaż tylko te badge’y które mają wartość != null. Cała sekcja ukryta gdy wszystkie 3 null.
Kontrakt:
- Import z
lucide-react:Clock,Flame,Timer(lub równoważne) - Import
formatMinutesz@/lib/format-minutes - Renderuj sekcję między
<h1>{title}</h1>blockiem a sekcją Składników (lub w bloku metadata obok category badge — uzgodnij wizualnie z istniejącym layoutem) - Conditional render:
{(prep || cook || total) && <section>...badge'y...</section>} - Labelki PL: „Przygotowanie”, „Gotowanie”, „Łącznie”
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - ESLint przechodzi na zmienionych plikach:
pnpm exec eslint src/lib/format-minutes.ts "src/app/(authenticated)/recipes/[slug]/page.tsx"
Weryfikacja ręczna
- Otwórz detail page przepisu który MA czasy → widoczne badge’y z ikonami i poprawnym formatem PL
- Otwórz detail page przepisu który NIE ma czasów (np. po backfillu istniejącego przepisu jeśli AI nie znalazł) → sekcja ukryta
- Zmień jedno z pól na null w bazie ręcznie, odśwież → ten badge nie pokazuje się, pozostałe widoczne
Uwaga implementacyjna: Po zakończeniu fazy zatrzymaj się na manual confirmation.
Faza 3: Bulk refresh existing recipes
Przegląd
Rozszerz Inngest URL-collision branch żeby wypełniał luki (gdy istniejący przepis ma null w polu, ale nowa ekstrakcja zwróciła wartość, wypełnij). Dodaj jednorazowy skrypt scripts/refresh-recipe-times.ts który wyzwala Inngest event dla każdego przepisu z brakującymi czasami.
Wymagane zmiany
1. Inngest function: update-gaps w URL collision branch
Plik: src/inngest/functions.ts
Cel: Gdy insert do recipes zwraca recipes_user_source_url_uniq violation i lookup zwraca existing recipe, NOWE zachowanie: zrób update existing recipe ustawiając pola na nowe wartości TYLKO tam gdzie istniejące to null (gap-fill semantics). Zachowaj obecne zachowanie linkowania share’a do existing recipe.
Kontrakt:
- W URL-collision branch (
src/inngest/functions.tspo Phase 3 recipe-url-dedup), po znalezieniu existing recipe:- Skonstruuj update payload — dla każdego pola czasu z RecipeData, jeśli existing recipe ma null AND nowa ekstrakcja zwróciła wartość, dołącz do update
- Jeśli update payload nie jest pusty:
supabase.from('recipes').update(updatePayload).eq('id', existing.id) - Po update, kontynuuj obecne zachowanie (linkowanie share’a)
- Pułapka: pole
existed: id/select 'id'musi też pobrać aktualne wartości czasów żeby porównać null. Zmodyfikujselect('id')naselect('id, prep_time_minutes, cook_time_minutes, total_time_minutes').
2. Refresh script
Plik: scripts/refresh-recipe-times.ts (nowy)
Cel: Skrypt CLI który dla każdego rekordu w recipes mającego prep_time_minutes IS NULL OR cook_time_minutes IS NULL OR total_time_minutes IS NULL, fires Inngest event recipe/extract z istniejącym shareId i URL. Inngest function (rozszerzona w #1) wypełni luki.
Kontrakt:
- Import wzorca z
scripts/check-auth.ts(env loading) - Service-role Supabase client (lub anon-key + RLS dla autora)
- Query:
select r.id, r.source_url, r.user_id, r.source_type, rs.id as share_id from recipes r join recipe_shares rs on rs.recipe_id = r.id where r.prep_time_minutes is null or r.cook_time_minutes is null or r.total_time_minutes is null - Dla każdego:
await inngest.send({ name: 'recipe/extract', data: { shareId, sharedUrl: source_url, userId: user_id, sourceType: source_type } }) - Log każdy wyzwolony event
- Run via
node --env-file=.env.local --import tsx scripts/refresh-recipe-times.ts
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - ESLint przechodzi:
pnpm exec eslint src/inngest/functions.ts scripts/refresh-recipe-times.ts
Weryfikacja ręczna
- Uruchom
pnpm exec tsx scripts/refresh-recipe-times.tspo deployu Phase 1+2 — w konsoli logi „Triggered refresh for recipe N (URL …)” dla każdego przepisu - W Inngest dashboard widać runs dla recipe/extract
- Po ~1-3 min:
select id, title, prep_time_minutes, cook_time_minutes, total_time_minutes from recipespokazuje wypełnione wartości dla istniejącego przepisu (Tortilla z patelni) - Detail page Tortilli pokazuje czasy
Uwaga implementacyjna: Po zakończeniu fazy zatrzymaj się na manual confirmation.
Strategia testowania
Testy jednostkowe
format-minutes.ts: 0 min, < 60, == 60, > 60 dzielne, > 60 z resztą, duże liczby. (Pomiń jeśli brak vitest setup — tak jak w recipe-url-dedup Phase 1.)
Testy integracyjne
- Brak nowych — istniejące Playwright e2e dla add-recipe form powinny dalej działać niezmienione.
Kroki testowania ręcznego
- Po Phase 1: udostępnij nowy URL przepisu, sprawdź że czasy zostały zapisane w bazie
- Po Phase 2: otwórz detail page nowego przepisu — widoczne badge’y z czasami
- Po Phase 3: uruchom skrypt, sprawdź że Tortilla z patelni ma czasy w bazie i na detail page
Uwagi dotyczące wydajności
Brak istotnych. Dodanie 3 kolumn int nullable to brak praktycznego wpływu. OpenAI prompt rośnie o ~5 linii — pomijalne. Render dodaje 3 conditional spany.
Uwagi dotyczące migracji
- Migration idempotentna (
alter table ... add column if not exists— albo standardowyadd column, schema check sam ostrzeże). - Rollback:
alter table public.recipes drop column prep_time_minutes(itd.). Stracimy dane czasów.
Referencje
- Schema:
supabase/migrations/20260607000000_recipe_schema.sql - Inngest function po Phase 3 recipe-url-dedup:
src/inngest/functions.ts - Detail page:
src/app/(authenticated)/recipes/[slug]/page.tsx - Lesson o env validation:
context/foundation/lessons.md:64-72 - Roadmap S-04 (overlap deferral):
context/foundation/roadmap.md:147-157
Postęp
Konwencja:
- [ ]oczekujące,- [x]wykonane. Dodaj— <commit sha>przy realizacji.
Faza 1: Schema + extraction
Automatyczne
- 1.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— f98f606 - 1.2 Migration aplikuje się:
pnpm exec supabase db push— f98f606 - 1.3 ESLint przechodzi na zmienionych plikach — f98f606
Ręczne
- 1.4 Schema query potwierdza 3 nowe nullable int kolumny — f98f606
- 1.5 Nowy share przepisu z bloga zwraca wartości w nowych polach (gdy obecne w treści) — 1e10eb2
Faza 2: Render
Automatyczne
- 2.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— cd74892 - 2.2 ESLint przechodzi na zmienionych plikach — cd74892
Ręczne
- 2.3 Detail page przepisu z czasami pokazuje badge’y z poprawnym formatem PL — cd74892
- 2.4 Detail page bez czasów (wszystkie null) nie pokazuje sekcji — cd74892
- 2.5 Częściowe dane (1 z 3 null) renderuje tylko niepuste badge’y — cd74892
Faza 3: Bulk refresh existing recipes
Automatyczne
- 3.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— 1e10eb2 - 3.2 ESLint przechodzi na zmienionych plikach — 1e10eb2
Ręczne
- 3.3 Skrypt loguje wyzwolone eventy — 1e10eb2
- 3.4 Po Inngest run, Tortilla z patelni ma wypełnione czasy (10/10/18 min) — 1e10eb2
- 3.5 Detail page Tortilli pokazuje sekcję czasów — 1e10eb2