Add more example recipes
All checks were successful
Build and Deploy / build-and-push (push) Successful in 27s

This commit is contained in:
2026-03-27 22:52:14 +01:00
parent 58ff081bc8
commit 7944ad2dbf
13 changed files with 458 additions and 603 deletions

View File

@@ -1,6 +1,6 @@
# Widoki i scenariusze — Aplikacja Kuchenna # Widoki i scenariusze — Aplikacja Kuchenna
> **Cel dokumentu:** Opis wszystkich widoków aplikacji z obecnym stanem, scenariusze użytkownika i znane problemy. Odniesienie dla dalszego rozwoju. > **Cel dokumentu:** Opis widoków, przepływów i scenariuszy użytkownika. Odniesienie dla dalszego rozwoju.
> **Kontekst projektu:** To jest **prototyp / mockup** — celem jest wypracowanie UX, logiki widoków i przepływów użytkownika. Finalna aplikacja będzie pisana w innym języku z backendem. Wartość tego prototypu to przede wszystkim: struktura widoków, scenariusze, model danych i decyzje UX — nie kod sam w sobie. > **Kontekst projektu:** To jest **prototyp / mockup** — celem jest wypracowanie UX, logiki widoków i przepływów użytkownika. Finalna aplikacja będzie pisana w innym języku z backendem. Wartość tego prototypu to przede wszystkim: struktura widoków, scenariusze, model danych i decyzje UX — nie kod sam w sobie.
@@ -11,14 +11,15 @@
| Cecha | Opis | | Cecha | Opis |
|-------|------| |-------|------|
| Liczba osób | 1 (gotuje dla siebie) | | Liczba osób | 1 (gotuje dla siebie) |
| Planowanie | Elastyczne, 17 dni do przodu | | Planowanie | Mieszane — część z wyprzedzeniem (np. niedziela na tydzień), część ad hoc na bieżąco |
| Posiłki | 5 slotów (śniadanie, drugie śniadanie, obiad, przekąska, kolacja) | | Posiłki | 5 slotów (śniadanie, drugie śniadanie, obiad, przekąska, kolacja) |
| Powtarzalność | Duża (zwłaszcza śniadania) — kopiowanie dnia kluczowe | | Powtarzalność | Duża (zwłaszcza śniadania), ale chce stopniowo urozmaicać |
| Styl gotowania | Hybrydowy: trochę meal-prep, trochę na bieżąco | | Styl gotowania | Hybrydowy: głównie na bieżąco, ale chce zacząć gotować na zapas (np. zupy na kilka dni) |
| Zakupy | Mieszane: duże zakupy + uzupełnienia w tygodniu | | Jedzenie poza domem | 23× w tygodniu (restauracja, kantyna, zamówienie) — stąd jawne "Pomijam" |
| Cel dietetyczny | Utrzymanie wagi, śledzenie per posiłek | | Zakupy | Duże zakupy raz w tygodniu + drobne uzupełnienia |
| Pomijanie posiłków | Jawne "Pomijam" (jedzenie na mieście itp.) | | Cel dietetyczny | Świadomość makro — chce widzieć wartości, ale nie liczy co do grama |
| Przepisy | Katalog wbudowany (9 przepisów, 24 składniki), bez edytora | | Bóle | Brak inspiracji ("co ugotować?"), marnowanie jedzenia, brak czasu w tygodniu, chaotyczne zakupy |
| Przepisy | Katalog wbudowany (6 przepisów, 34 składniki), bez edytora |
--- ---
@@ -45,7 +46,8 @@ js/
views/ views/
RecipeList.js ← lista przepisów RecipeList.js ← lista przepisów
Filter.js ← overlay filtrów Filter.js ← overlay filtrów
RecipeDetail.js ← detal przepisu (slide-in overlay) RecipeDetailV2.js ← detal przepisu (aktywna wersja)
RecipeDetail.js ← detal przepisu (oryginał, nieużywany — 3-zakładkowy)
MealPlanner.js ← planer posiłków + kalendarz MealPlanner.js ← planer posiłków + kalendarz
Pantry.js ← spiżarnia Pantry.js ← spiżarnia
Shopping.js ← listy zakupów Shopping.js ← listy zakupów
@@ -68,128 +70,42 @@ js/
### 3.1 Przepisy (`RecipeList`) ### 3.1 Przepisy (`RecipeList`)
**Lokalizacja:** `js/views/RecipeList.js` Siatka 2-kolumnowa kart przepisów z wyszukiwarką i filtrami. Kliknięcie karty otwiera detal. Filtry (overlay `Filter.js`): pory posiłku, tagi dietetyczne, suwak czasu.
Siatka 2-kolumnowa kart przepisów generowana dynamicznie z `RECIPES`. Każda karta zawiera miniaturę (placeholder), tytuł, opis, czas przygotowania, kalorie, chipy slotów. Kliknięcie otwiera detal. ### 3.2 Szczegóły przepisu (`RecipeDetailV2`)
**Elementy:** Slide-in overlay z detalami przepisu. Dwie zakładki: **Składniki** i **Kroki**.
- Wyszukiwarka (real-time, po tytule i tagach)
- Przycisk filtrów (otwiera overlay)
- Siatka kart
- Empty state gdy brak wyników
**Stan:** Kompletny. **Zakładka Składniki:**
- Podsumowanie wartości odżywczych na górze (4-kolumnowa siatka: kalorie, białko, węgle, tłuszcze) — przeliczane dynamicznie z uwzględnieniem wybranych zamienników
- Składniki jako karty z wartościami odżywczymi per składnik (makro + kcal po prawej, obok gramów)
- **Wymienne składniki** — kliknięcie ikony shuffle rozwija listę opcji (oryginał + alternatywy) z radio-przyciskami; wybranie zamiennika: karta zmienia się na wybraną opcję (amber obramowanie), podsumowanie kaloryczne przelicza się na żywo
- Selektor porcji (±, zakres 112) przelicza składniki i wartości
--- **Bottom sheet "Zaplanuj":**
1. Kalendarz (tydzień/miesiąc, nawigacja ←/→/Dziś)
2. Pora posiłku — chipy filtrowane do `allowedSlots` przepisu
3. Wymienne składniki — **wstępnie ustawione z wyborów na liście składników**, z możliwością dalszej zmiany
4. Przycisk "Dodaj" → zapis do `planStore` (z opcjonalnym obiektem `substitutions`)
### 3.2 Filtry (`Filter`) > **Uwaga:** Istnieje starsza wersja (`RecipeDetail.js`) z 3 zakładkami (Składniki, Kroki, Wartości) i read-only alternatywami. Aktualnie nieużywana — import w `app.js` wskazuje na `RecipeDetailV2.js`.
**Lokalizacja:** `js/views/Filter.js`
Full-screen overlay (z-50) z chipami pór posiłku, tagami dietetycznymi i suwakiem czasu. Filtr zamknięty przyciskiem ← odrzuca niezapisane zmiany; "Pokaż X wyników" aplikuje i zamyka.
**Elementy:**
- Chipy: pory posiłku (z `MEAL_SLOTS`)
- Chipy: tagi dietetyczne (zbierane dynamicznie z `RECIPES`)
- Suwak: maksymalny czas przygotowania (5120 min)
- Przycisk "Wyczyść" + "Pokaż X wyników"
**Stan:** Kompletny.
---
### 3.3 Szczegóły przepisu (`RecipeDetail`)
**Lokalizacja:** `js/views/RecipeDetail.js`
Slide-in overlay z detalami przepisu. Trzy zakładki: Składniki, Kroki, Wartości.
**Elementy:**
- Hero (placeholder) + strzałka powrotu + przycisk "Zaplanuj"
- Tytuł, tagi (sloty + tagi przepisu), czas, kcal
- Selektor porcji (± , zakres 112) z przeliczaniem składników i wartości
- Zakładka Składniki: lista read-only (bez checkboxów, bez badge'ów spiżarni). Składniki z wymiennymi wariantami mają ikonę shuffle — kliknięcie rozwija karty z alternatywami i ich wartościami odżywczymi (informacyjnie, bez możliwości wyboru)
- Zakładka Kroki: numerowane kroki
- Zakładka Wartości: kcal/białko/tłuszcze/węglowodany × porcje
- Bottom sheet "Zaplanuj":
1. Kalendarz (tydzień/miesiąc, nawigacja ←/→/Dziś, toggle rozwinięcia) — styl ujednolicony z `MealPlanner`
2. Pora posiłku — chipy filtrowane do `allowedSlots` przepisu
3. Wymienne składniki (opcjonalne, widoczne tylko gdy przepis ma `alternatives`) — kompaktowe karty per składnik wyświetlające aktualny wybór z wartościami odżywczymi; kliknięcie rozwija listę opcji z radio-przyciskami; po wyborze karta się zwija; zmieniony składnik ma amber tło
4. Przycisk "Dodaj" → zapis do `planStore` (z opcjonalnym obiektem `substitutions`)
**Model danych — wymienne składniki:** **Model danych — wymienne składniki:**
- W `RECIPES`, składnik może mieć pole `alternatives: ['id1', 'id2', ...]` — tablica ID alternatywnych składników - W `RECIPES`, składnik może mieć pole `alternatives: ['id1', 'id2', ...]`
- Przy dodawaniu do planera, wybrane zamienniki zapisywane jako `substitutions: { originalId: chosenAltId }` w `planStore` - Wybrane zamienniki zapisywane jako `substitutions: { originalId: chosenAltId }` w `planStore`
- Przykład: serek wiejski ma 3 wymienne składniki — orzechy (5 opcji), truskawki (banany), borówki (jagody) - Przykład: serek wiejski ma 3 wymienne składniki — orzechy (5 opcji), truskawki (banany), borówki (jagody)
**Stan:** Kompletny. ### 3.3 Planer posiłków (`MealPlanner`)
--- Kalendarz (tydzień/miesiąc) + plan dnia z 5 slotami posiłków. Karty przepisów z porcjami (±), kcal, usuwaniem. "Pomijam" przy pustym slocie. Podsumowanie kaloryczne dnia. "Składniki na ten dzień" z porównaniem do spiżarni i prognozą tygodniową. Kopiowanie planu dnia. Picker przepisów do dodawania. Demo-dane przy pustym localStorage.
### 3.4 Planer posiłków (`MealPlanner`) ### 3.4 Spiżarnia (`Pantry`)
**Lokalizacja:** `js/views/MealPlanner.js` Chipy składników pogrupowane po kategorii z kolorami wg stanu. Wyszukiwarka, filtry kategorii, toggle "Tylko na stanie". Bottom sheet edycji z ± i inputem, wartościami odżywczymi, "Dodaj na listę zakupów".
Kalendarz (tydzień/miesiąc) + plan dnia z 5 slotami posiłków. ### 3.5 Zakupy (`Shopping`)
**Elementy kalendarza:** Lista kuchenna (pogrupowana po kategorii, checkboxy, edycja ilości) + listy freeform. Pasek akcji dla zaznaczonych: "Kupione → spiżarnia" (z podglądem) i "Wyczyść kupione".
- Widok tygodnia ↔ miesiąca (swipe góra/dół na `#calendar-swipe-zone`)
- Nawigacja: ←/→ + "Dziś"
- Kropki pod dniem = zaplanowane posiłki
**Elementy planu dnia:**
- Nagłówek dnia + przycisk "Kopiuj dzień"
- Karta podsumowania kalorycznego (kcal + makro, rozwijalne szczegóły)
- "Składniki na ten dzień" (badge z liczbą braków vs "OK")
- Sloty posiłków (5 slotów z `MEAL_SLOTS`):
- Kcal per slot w nagłówku
- Karty przepisów z porcjami (±), kcal, czasem, przyciskiem usuwania
- Kliknięcie nazwy przepisu → `RecipeDetail`
- "Dodaj przepis" / "Dodaj kolejny"
- "Pomijam" przy pustym slocie → slot przygaszony z "Cofnij"
**Bottom sheety:**
1. **Picker przepisów**: wyszukiwarka + sekcja "Ostatnio używane" + lista filtrowana do `allowedSlots`
2. **Składniki i spiżarnia**: porównanie potrzeb vs zapasy + prognoza tygodniowa (`computeFullForecast`) + "Dodaj braki" (dzień / tydzień)
3. **Kopiuj plan dnia**: lista 13 dni (3 wstecz, 10 do przodu) → kopiuje cały dzień (w tym statusy "Pominięto")
**Demo:** `seedDemoIfEmpty` wypełnia dzisiejszy dzień danymi demo gdy localStorage jest pusty.
**Stan:** Kompletny.
---
### 3.5 Spiżarnia (`Pantry`)
**Lokalizacja:** `js/views/Pantry.js`
Przeglądanie i edycja stanów magazynowych składników.
**Elementy:**
- Wyszukiwarka
- Chipy filtrów kategorii (multi-select)
- Toggle "Tylko na stanie"
- Siatka chipów składników pogrupowana po kategorii (kolor wg stanu)
- Bottom sheet edycji (`#pv2-edit-sheet`): ± z krokiem (`pantryQtyStep`), input numeryczny, opcjonalne wartości odżywcze (per 100g), "Dodaj na listę zakupów" (z `purchasePack`)
**Stan:** Kompletny.
---
### 3.6 Zakupy (`Shopping`)
**Lokalizacja:** `js/views/Shopping.js`
Zarządzanie listami zakupów — jedna stała lista kuchenna + dowolna liczba list freeform.
**Elementy:**
- Selektor aktywnej listy (dropdown)
- Przycisk "Nowa lista" (freeform) + usuwanie list (nie dotyczy kuchennej)
- **Lista kuchenna** (`KITCHEN_LIST_ID`): pogrupowana po kategorii, checkbox, edycja ilości (klik → prompt), usuwanie pozycji
- **Pasek akcji** (widoczny gdy są zaznaczone pozycje): "Kupione → spiżarnia" (z potwierdzeniem i podglądem) + "Wyczyść kupione"
- **Lista freeform**: pozycje tekstowe z opcjonalną notatką, checkbox
**Stan:** Kompletny.
--- ---
@@ -197,7 +113,8 @@ Zarządzanie listami zakupów — jedna stała lista kuchenna + dowolna liczba l
``` ```
Przepisy ──[klik kartę]──→ Szczegóły przepisu Przepisy ──[klik kartę]──→ Szczegóły przepisu
──[Zaplanuj]──→ Bottom sheet (kalendarz + pora + opcjonalnie wymiana składników) → Planer ──[zamiana składnika]──→ podsumowanie przelicza się na żywo
└──[Zaplanuj]──→ Bottom sheet (kalendarz + pora + zamienniki z detalu) → Planer
Planer ──[klik przepis w slocie]──→ Szczegóły przepisu Planer ──[klik przepis w slocie]──→ Szczegóły przepisu
──[Składniki na ten dzień]──→ Sheet: porównanie z spiżarnią + prognoza ──[Składniki na ten dzień]──→ Sheet: porównanie z spiżarnią + prognoza
@@ -214,156 +131,57 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
## 5. Scenariusze użytkownika ## 5. Scenariusze użytkownika
### Scenariusz 1: Przeglądanie przepisów ### Niedzielne planowanie tygodnia
**Cel:** Użytkownik otwiera apkę, chce zobaczyć co jest dostępne. Wieczorem w niedzielę siada z apką i układa plan na najbliższe 45 dni. Kopiuje sprawdzone dni, dodaje nowe przepisy tam gdzie chce urozmaicenie. Przy kilku posiłkach z góry wie, że będzie jeść poza domem — oznacza je jako pominięte. Sprawdza podsumowanie składników na zaplanowane dni, generuje braki na listę zakupów. Rano idzie do sklepu z gotową listą.
1. Otwiera aplikację → widzi zakładkę **Przepisy** z siatką 9 kart **Co mu to daje:** Nie musi codziennie myśleć "co ugotować". Kupuje celowo — mniej marnowania.
2. Przewija listę, czyta opisy i kalorie na kartach
3. Klika kartę "Serek wiejski z orzechami i owocami"
4. Widzi detal: składniki, kroki, wartości odżywcze
5. Przy orzechach, truskawkach i borówkach widzi ikonę shuffle — klika ją przy orzechach
6. Rozwija się lista alternatyw (laskowe, nerkowca, migdały, pekan) z wartościami odżywczymi — informacyjnie
7. Zmienia liczbę porcji z 1 na 2 → składniki i kcal się przeliczają
8. Przełącza zakładkę na "Kroki" → widzi numerowane kroki
9. Przełącza na "Wartości" → widzi makroskładniki ×2
10. Wraca strzałką ← do listy
**Uwagi:** ### Poranek — rutynowe śniadanie z wariacją
- Brak zdjęć (szare placeholdery) — OK dla prototypu
- Po powrocie z detalu filtr/szukajka się utrzymują
--- Od tygodni je to samo śniadanie. Tym razem otwiera detal przepisu i klika shuffle przy jednym ze składników. Porównuje wartości odżywcze zamienników, wybiera coś innego. Podsumowanie kaloryczne przelicza się od razu — widzi, że różnica jest niewielka. Dodaje do planu z zamiennikiem.
### Scenariusz 2: Szukanie przepisu na kolację **Co mu to daje:** Urozmaicenie bez wysiłku. Widzi wpływ zamiany na makro zanim się zdecyduje.
**Cel:** Użytkownik szuka czegoś konkretnego. ### Środa wieczór — "nie chce mi się gotować"
1. Wpisuje "łosoś" → lista filtruje się do 1 karty Wraca zmęczony z pracy. Otwiera apkę — miał zaplanowany obiad, ale nie ma energii. Oznacza posiłek jako pominięty (zamówi albo zje na mieście). Albo: przegląda przepisy filtrując po czasie do 15 min, szybko coś wybiera i dodaje na dziś.
2. Kasuje tekst → wracają wszystkie
3. Klika ikonę filtrów → otwiera się overlay
4. Zaznacza "Kolacja" → aktualizuje się licznik wyników
5. Dodatkowo zaznacza tag "Wysokobiałkowe"
6. Ustawia suwak na max 25 min
7. Klika "Pokaż X wyników" → wraca do przefiltrowanej listy
8. Wybiera przepis i otwiera detal
--- **Co mu to daje:** Brak poczucia winy — pominięcie jest jawne, plan się dostosowuje. Albo szybka alternatywa bez przeglądania całego katalogu.
### Scenariusz 3: Planowanie posiłków na tydzień ### Gotowanie na zapas
**Cel:** Użytkownik układa menu na kilka dni. W weekend wybiera przepis na obiad i ustawia 3 porcje. Dodaje ten sam przepis na poniedziałek, wtorek i środę. Gotuje raz — ma obiady na trzy dni odhaczone. Składniki na liście zakupów są policzone na pełną ilość.
1. Przechodzi na zakładkę **Planer** **Co mu to daje:** Oszczędność czasu w tygodniu. Jedno gotowanie zamiast trzech.
2. Widzi dzisiejszy dzień (demo-dane lub wcześniej zaplanowane)
3. Klika na przepis w slocie → otwiera się detal
4. Wraca ← do planera
5. Klika następny dzień w kalendarzu
6. Widzi puste sloty, klika "Dodaj przepis" przy Śniadaniu
7. Picker: wpisuje fragment nazwy, widzi "Ostatnio używane"
8. Wybiera przepis → pojawia się w slocie z kcal w nagłówku
9. Przy obiedzie klika "Pomijam" (je na mieście) → slot przygaszony
10. Klika "Składniki na ten dzień" → widzi braki vs spiżarnia + prognoza
11. Klika "Dodaj braki na dziś do listy" → toast potwierdzenia
12. Następnego dnia: "Kopiuj dzień" → wybiera dzień docelowy → gotowe
--- ### W sklepie z listą
### Scenariusz 4: Zarządzanie spiżarnią Stoi w sklepie, otwiera listę zakupów. Produkty pogrupowane po kategoriach — idzie przez alejki i odznacza kolejne pozycje. Wraca do domu, klika "Kupione → spiżarnia" — stany magazynowe aktualizują się jednym ruchem.
**Cel:** Użytkownik sprawdza co ma w domu. **Co mu to daje:** Nie zapomina co kupić. Spiżarnia jest aktualna bez ręcznego wpisywania.
1. Przechodzi na zakładkę **Spiżarnia** ### Sprawdzenie przed zakupami
2. Widzi chipy składników pogrupowane po kategorii
3. Włącza "Tylko na stanie" → widzi co ma
4. Klika "Płatki owsiane" → bottom sheet
5. Ustawia 500g (przyciskami ± lub inputem)
6. Zamyka → chip zmienił się na zielony z "500 g"
7. Chce dodać mleko na listę → klika "Dodaj na listę" w sheecie
--- Przed wyjściem do sklepu sprawdza spiżarnię — co jeszcze ma. Przechodzi do planera, ogląda najbliższe dni i klika "Dodaj braki do listy". Lista zakupów zawiera tylko to, czego naprawdę potrzebuje.
### Scenariusz 5: Zakupy w sklepie **Co mu to daje:** Nie kupuje podwójnie. Nie marnuje jedzenia, które zalega w szafce.
**Cel:** Użytkownik jest w sklepie, odznacza kupione.
1. Otwiera zakładkę **Zakupy**
2. Widzi listę kuchenną pogrupowaną po kategorii
3. Bierze mleko z półki → klika checkbox → przekreślenie
4. Widzi `sourceNote` z informacją skąd pozycja pochodzi
5. Ilość się nie zgadza — klika na ilość → prompt → poprawia
6. Kupuje dalsze pozycje
---
### Scenariusz 6: Po zakupach — przeniesienie do spiżarni
**Cel:** Użytkownik wrócił ze sklepu, aktualizuje spiżarnię.
1. Otwiera **Zakupy** → widzi zaznaczone pozycje
2. Pojawia się pasek: "Kupione → spiżarnia" i "Wyczyść kupione"
3. Klika "Kupione → spiżarnia" → potwierdzenie z podglądem pozycji
4. Potwierdza → toast "Przeniesiono X pozycji"
5. Kupione znikają z listy
6. Przechodzi do **Spiżarni** → stany zaktualizowane
7. Wraca do **Planera** → braki zmniejszone
---
### Scenariusz 7: Gotowanie z przepisu
**Cel:** Użytkownik gotuje, sprawdza przepis krok po kroku.
1. Otwiera **Planer** → dzisiejszy dzień
2. Klika przepis w slocie Kolacja
3. Otwiera się detal → przełącza na "Kroki"
4. Czyta krok po kroku
5. Sprawdza ilość składnika → przełącza na "Składniki"
6. Wraca na "Kroki"
---
### Scenariusz 8: Dodanie przepisu do planera z widoku detalu
**Cel:** Użytkownik znalazł przepis i chce go zaplanować.
1. Przegląda **Przepisy** → otwiera detal "Serek wiejski z orzechami i owocami"
2. Klika "Zaplanuj" (górny przycisk)
3. Otwiera się bottom sheet z kalendarzem (domyślnie widok tygodnia, można rozwinąć do miesiąca)
4. Wybiera dzień w kalendarzu
5. Wybiera porę posiłku (np. "Śniadanie")
6. Widzi sekcję "Wymienne składniki" — 3 kompaktowe karty (orzechy, truskawki, borówki) z aktualnym wyborem
7. Klika kartę "Orzechy włoskie" → rozwija się lista opcji (włoskie, laskowe, nerkowca, migdały, pekan) z wartościami odżywczymi i radio-przyciskami
8. Wybiera "Migdały" → karta się zwija, wybrany składnik zmienia się na "Migdały" z amber tłem
9. Klika "Dodaj" → toast potwierdzenia, sheet się zamyka, przepis dodany do planera z informacją o zamianie (substitutions)
---
### Scenariusz 9: "Co mogę ugotować?"
**Cel:** Użytkownik ma coś w spiżarni, chce wiedzieć co da się z tego zrobić.
1. Otwiera **Spiżarnię** → widzi co ma
2. Chciałby kliknąć "Co mogę ugotować?" → **takiej funkcji jeszcze nie ma**
3. Musi ręcznie sprawdzać przepisy
**Propozycja:** Filtr "Mam składniki" w widoku Przepisy.
--- ---
## 6. Znane problemy i propozycje ulepszeń ## 6. Znane problemy i propozycje ulepszeń
### Do poprawy (TODO) ### Do poprawy
| # | Problem | Dotyczy scenariusza | | # | Problem |
|---|---------|---------------------| |---|---------|
| 1 | Brak wskaźnika aktywnych filtrów na ikonce (badge/kropka) | 2 | | 1 | Brak wskaźnika aktywnych filtrów na ikonce (badge/kropka) |
| 2 | Po dodaniu braków do listy zakupów brak ochrony przed duplikacją (brak info "już dodano") | 3 | | 2 | Po dodaniu braków do listy zakupów brak ochrony przed duplikacją |
| 3 | Kupione pozycje mieszają się z niekupionymi w tej samej grupie (brak separacji) | 5 | | 3 | Kupione pozycje mieszają się z niekupionymi w tej samej grupie |
| 4 | Brak podsumowania na liście zakupów ("Do kupienia: X, kupione: Y") | 5 | | 4 | Brak podsumowania na liście zakupów ("Do kupienia: X, kupione: Y") |
| 5 | Brak undo przy "Kupione → spiżarnia" | 6 | | 5 | Brak undo przy "Kupione → spiżarnia" |
| 6 | Scroll spiżarni resetuje się po edycji (re-render) | 4 | | 6 | Scroll spiżarni resetuje się po edycji (re-render) |
| 7 | "Kopiuj dzień" kopiuje też status "Pominięto" — może nie zawsze pożądane | 3 | | 7 | "Kopiuj dzień" kopiuje też status "Pominięto" — może nie zawsze pożądane |
### Propozycje nowych funkcji ### Propozycje nowych funkcji

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View File

@@ -47,6 +47,6 @@
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {}); navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {});
} }
</script> </script>
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js?v=2"></script>
</body> </body>
</html> </html>

View File

@@ -1,9 +1,9 @@
import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js'; import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js?v=2';
import { getFilterHTML, setupFilter } from './views/Filter.js'; import { getFilterHTML, setupFilter } from './views/Filter.js?v=2';
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetailV2.js'; import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetailV2.js?v=2';
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js'; import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js?v=2';
import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js'; import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js?v=2';
import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js'; import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js?v=2';
function getAppToastHTML() { function getAppToastHTML() {
return ` return `

View File

@@ -24,21 +24,16 @@ export const CATEGORY_LABELS = {
/** @type {Record<string, IngredientDef>} */ /** @type {Record<string, IngredientDef>} */
export const INGREDIENTS = { export const INGREDIENTS = {
maka_pszenna: { /* ── Pieczywo ─────────────────────────────────────── */
id: 'maka_pszenna', bulka_grahamka: {
name: 'Mąka pszenna', id: 'bulka_grahamka',
category: 'suche', name: 'Bułka grahamka',
pantryUnit: 'g', category: 'pieczywo',
nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 }, pantryUnit: 'szt',
}, purchasePack: { amount: 1, label: '1 bułka ~70 g' },
mleko: { nutritionPer100g: { kcal: 260, protein: 9, fat: 3, carbs: 48 },
id: 'mleko',
name: 'Mleko',
category: 'nabial',
pantryUnit: 'ml',
purchasePack: { amount: 1000, label: 'butelka 1 l' },
nutritionPer100g: { kcal: 42, protein: 3.4, fat: 1, carbs: 5 },
}, },
/* ── Nabiał ───────────────────────────────────────── */
jajko: { jajko: {
id: 'jajko', id: 'jajko',
name: 'Jajka', name: 'Jajka',
@@ -46,131 +41,21 @@ export const INGREDIENTS = {
pantryUnit: 'szt', pantryUnit: 'szt',
nutritionPer100g: { kcal: 143, protein: 13, fat: 9.5, carbs: 1.1 }, nutritionPer100g: { kcal: 143, protein: 13, fat: 9.5, carbs: 1.1 },
}, },
piers_kurczaka: { mozzarella: {
id: 'piers_kurczaka', id: 'mozzarella',
name: 'Pierś z kurczaka', name: 'Mozzarella',
category: 'mieso_ryby',
pantryUnit: 'g',
nutritionPer100g: { kcal: 165, protein: 31, fat: 3.6, carbs: 0 },
},
mix_salat: {
id: 'mix_salat',
name: 'Mix sałat',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 20, protein: 1.5, fat: 0.3, carbs: 3 },
},
pomidor: {
id: 'pomidor',
name: 'Pomidor',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 },
},
makaron_suchy: {
id: 'makaron_suchy',
name: 'Makaron',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 371, protein: 13, fat: 1.5, carbs: 74 },
},
pomidory_krojone: {
id: 'pomidory_krojone',
name: 'Pomidory krojone (puszka)',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 20, protein: 1, fat: 0.2, carbs: 4 },
},
bazylia_swieza: {
id: 'bazylia_swieza',
name: 'Bazylia świeża',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 23, protein: 3.2, fat: 0.6, carbs: 2.7 },
},
jogurt_naturalny: {
id: 'jogurt_naturalny',
name: 'Jogurt naturalny',
category: 'nabial', category: 'nabial',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 61, protein: 3.5, fat: 3.3, carbs: 4.7 }, purchasePack: { amount: 125, label: 'kulka 125 g' },
nutritionPer100g: { kcal: 280, protein: 22, fat: 20, carbs: 2 },
}, },
mieszanka_jagod: { ricotta: {
id: 'mieszanka_jagod', id: 'ricotta',
name: 'Mieszanka jagód', name: 'Ricotta',
category: 'owoce', category: 'nabial',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 50, protein: 0.7, fat: 0.3, carbs: 12 }, purchasePack: { amount: 250, label: 'opakowanie 250 g' },
}, nutritionPer100g: { kcal: 174, protein: 11, fat: 13, carbs: 3 },
miod: {
id: 'miod',
name: 'Miód',
category: 'inne',
pantryUnit: 'g',
nutritionPer100g: { kcal: 304, protein: 0.3, fat: 0, carbs: 82 },
},
chleb_zakwas: {
id: 'chleb_zakwas',
name: 'Chleb na zakwasie',
category: 'pieczywo',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 250, protein: 9, fat: 1.5, carbs: 49 },
},
awokado: {
id: 'awokado',
name: 'Awokado',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 160, protein: 2, fat: 15, carbs: 9 },
},
cytryna: {
id: 'cytryna',
name: 'Cytryna',
category: 'owoce',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 29, protein: 1.1, fat: 0.3, carbs: 9 },
},
losos_filet: {
id: 'losos_filet',
name: 'Filet z łososia',
category: 'mieso_ryby',
pantryUnit: 'g',
nutritionPer100g: { kcal: 208, protein: 20, fat: 13, carbs: 0 },
},
koper_swiezy: {
id: 'koper_swiezy',
name: 'Koper',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 43, protein: 3.5, fat: 1.1, carbs: 7 },
},
mieso_wol_mielone: {
id: 'mieso_wol_mielone',
name: 'Mięso mielone wołowe',
category: 'mieso_ryby',
pantryUnit: 'g',
nutritionPer100g: { kcal: 250, protein: 26, fat: 15, carbs: 0 },
},
tortilla_kukurydziana: {
id: 'tortilla_kukurydziana',
name: 'Tortille kukurydziane',
category: 'pieczywo',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 218, protein: 5.7, fat: 2.9, carbs: 44 },
},
salsa_pomidorowa: {
id: 'salsa_pomidorowa',
name: 'Salsa pomidorowa',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 36, protein: 1.5, fat: 0.2, carbs: 8 },
},
platki_owsiane: {
id: 'platki_owsiane',
name: 'Płatki owsiane',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 389, protein: 17, fat: 7, carbs: 66 },
}, },
serek_wiejski: { serek_wiejski: {
id: 'serek_wiejski', id: 'serek_wiejski',
@@ -180,6 +65,127 @@ export const INGREDIENTS = {
purchasePack: { amount: 200, label: 'opakowanie 200 g' }, purchasePack: { amount: 200, label: 'opakowanie 200 g' },
nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 }, nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 },
}, },
serek_smietankowy: {
id: 'serek_smietankowy',
name: 'Serek śmietankowy',
category: 'nabial',
pantryUnit: 'g',
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
nutritionPer100g: { kcal: 230, protein: 6, fat: 21, carbs: 4 },
},
/* ── Mięso i ryby ─────────────────────────────────── */
szynka_parmenska: {
id: 'szynka_parmenska',
name: 'Szynka parmeńska',
category: 'mieso_ryby',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
nutritionPer100g: { kcal: 250, protein: 28, fat: 15, carbs: 0 },
},
szynka_z_kurczaka: {
id: 'szynka_z_kurczaka',
name: 'Szynka z kurczaka',
category: 'mieso_ryby',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
nutritionPer100g: { kcal: 105, protein: 19.5, fat: 2, carbs: 1.5 },
},
losos_wedzony: {
id: 'losos_wedzony',
name: 'Łosoś wędzony',
category: 'mieso_ryby',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
nutritionPer100g: { kcal: 150, protein: 20, fat: 7, carbs: 0 },
},
/* ── Warzywa ──────────────────────────────────────── */
pomidor: {
id: 'pomidor',
name: 'Pomidor',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 },
},
pomidorki_koktajlowe: {
id: 'pomidorki_koktajlowe',
name: 'Pomidorki koktajlowe',
category: 'warzywa',
pantryUnit: 'g',
purchasePack: { amount: 250, label: 'opakowanie 250 g' },
nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 },
},
papryka_czerwona: {
id: 'papryka_czerwona',
name: 'Papryka czerwona',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 31, protein: 1, fat: 0.3, carbs: 6 },
},
ogorek: {
id: 'ogorek',
name: 'Ogórek',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 15, protein: 0.7, fat: 0.1, carbs: 3 },
},
czosnek: {
id: 'czosnek',
name: 'Czosnek',
category: 'warzywa',
pantryUnit: 'szt',
nutritionPer100g: { kcal: 149, protein: 6.4, fat: 0.5, carbs: 33 },
},
kielki_rzodkiewki: {
id: 'kielki_rzodkiewki',
name: 'Kiełki rzodkiewki',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 28, protein: 3, fat: 0.6, carbs: 3.5 },
},
/* ── Owoce ────────────────────────────────────────── */
truskawki: {
id: 'truskawki',
name: 'Truskawki',
category: 'owoce',
pantryUnit: 'g',
nutritionPer100g: { kcal: 32, protein: 0.7, fat: 0.3, carbs: 8 },
},
borowki_amerykanskie: {
id: 'borowki_amerykanskie',
name: 'Borówki amerykańskie',
category: 'owoce',
pantryUnit: 'g',
nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 },
},
banany: {
id: 'banany',
name: 'Banany',
category: 'owoce',
pantryUnit: 'g',
nutritionPer100g: { kcal: 89, protein: 1.1, fat: 0.3, carbs: 23 },
},
jagody: {
id: 'jagody',
name: 'Jagody',
category: 'owoce',
pantryUnit: 'g',
nutritionPer100g: { kcal: 44, protein: 0.7, fat: 0.4, carbs: 10 },
},
/* ── Suche i kasze ────────────────────────────────── */
makaron_suchy: {
id: 'makaron_suchy',
name: 'Makaron',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 371, protein: 13, fat: 1.5, carbs: 74 },
},
nasiona_slonecznika: {
id: 'nasiona_slonecznika',
name: 'Nasiona słonecznika',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 584, protein: 21, fat: 51, carbs: 20 },
},
orzechy_wloskie: { orzechy_wloskie: {
id: 'orzechy_wloskie', id: 'orzechy_wloskie',
name: 'Orzechy włoskie', name: 'Orzechy włoskie',
@@ -215,202 +221,192 @@ export const INGREDIENTS = {
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 }, nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 },
}, },
truskawki: { /* ── Przyprawy i zioła ────────────────────────────── */
id: 'truskawki', bazylia_swieza: {
name: 'Truskawki', id: 'bazylia_swieza',
category: 'owoce', name: 'Bazylia świeża',
category: 'przyprawy',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 32, protein: 0.7, fat: 0.3, carbs: 8 }, nutritionPer100g: { kcal: 23, protein: 3.2, fat: 0.6, carbs: 2.7 },
}, },
borowki_amerykanskie: { koper_swiezy: {
id: 'borowki_amerykanskie', id: 'koper_swiezy',
name: 'Borówki amerykańskie', name: 'Koper',
category: 'owoce', category: 'przyprawy',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 }, nutritionPer100g: { kcal: 43, protein: 3.5, fat: 1.1, carbs: 7 },
}, },
banany: { szczypiorek: {
id: 'banany', id: 'szczypiorek',
name: 'Banany', name: 'Szczypiorek',
category: 'owoce', category: 'przyprawy',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 89, protein: 1.1, fat: 0.3, carbs: 23 }, nutritionPer100g: { kcal: 30, protein: 3.3, fat: 0.7, carbs: 1.8 },
}, },
jagody: { tymianek: {
id: 'jagody', id: 'tymianek',
name: 'Jagody', name: 'Tymianek suszony',
category: 'owoce', category: 'przyprawy',
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 44, protein: 0.7, fat: 0.4, carbs: 10 }, nutritionPer100g: { kcal: 276, protein: 9, fat: 7, carbs: 45 },
},
chrzan: {
id: 'chrzan',
name: 'Chrzan tarty',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 44, protein: 1, fat: 0.5, carbs: 8 },
},
/* ── Inne ─────────────────────────────────────────── */
miod: {
id: 'miod',
name: 'Miód',
category: 'inne',
pantryUnit: 'g',
nutritionPer100g: { kcal: 304, protein: 0.3, fat: 0, carbs: 82 },
},
oliwa: {
id: 'oliwa',
name: 'Oliwa z oliwek',
category: 'inne',
pantryUnit: 'ml',
nutritionPer100g: { kcal: 884, protein: 0, fat: 100, carbs: 0 },
},
hummus: {
id: 'hummus',
name: 'Hummus',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 200, label: 'opakowanie 200 g' },
nutritionPer100g: { kcal: 166, protein: 8, fat: 10, carbs: 14 },
}, },
}; };
/** Porcja bazowa = 1; składniki przez ingredientId */ /** Porcja bazowa = 1; składniki przez ingredientId */
export const RECIPES = { export const RECIPES = {
placki: { kanapka_parmenska: {
id: 'placki', id: 'kanapka_parmenska',
title: 'Puszyste placki', title: 'Kanapka z szynką parmeńską i mozzarellą',
description: 'Klasyczne placki na śniadanie — puszyste i złociste.', description: 'Bułka grahamka z szynką parmeńską, mozzarellą i pomidorkami — włoskie smaki na szybko.',
minutes: 15,
thumbLabel: 'Placki',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
tags: ['wegetariańskie', 'słodkie'],
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
ingredients: [
{ ingredientId: 'maka_pszenna', amount: 200, unit: 'g' },
{ ingredientId: 'mleko', amount: 250, unit: 'ml' },
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
],
steps: [
'Mąkę przesiej do miski, dodaj szczyptę soli.',
'Wbij jajka, wlej mleko i wymieszaj trzepaczką na gładkie ciasto.',
'Rozgrzej patelnię z odrobiną masła na średnim ogniu.',
'Nakładaj ciasto łyżką wazową i smaż placki po ok. 2 min z każdej strony.',
],
},
salatka: {
id: 'salatka',
title: 'Sałatka z kurczakiem',
description: 'Zielone warzywa z grillowanym kurczakiem.',
minutes: 20,
thumbLabel: 'Sałatka',
allowedSlots: ['obiad'],
tags: ['wysokobiałkowe', 'niskokaloryczne'],
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
ingredients: [
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
{ ingredientId: 'mix_salat', amount: 100, unit: 'g' },
{ ingredientId: 'pomidor', amount: 1, unit: 'szt.' },
],
steps: [
'Pierś z kurczaka przypraw solą i pieprzem, griluj na patelni ok. 5 min z każdej strony.',
'Pokrój kurczaka w paski i odłóż do ostygnięcia.',
'Wymieszaj mix sałat z pokrojonym pomidorem.',
'Ułóż kurczaka na sałatce, polej ulubionym dressingiem.',
],
},
makaron: {
id: 'makaron',
title: 'Makaron z pomidorami i bazylią',
description: 'Aromatyczny sos pomidorowy z czosnkiem i świeżą bazylią.',
minutes: 30,
thumbLabel: 'Makaron',
allowedSlots: ['obiad', 'kolacja'],
tags: ['wegetariańskie', 'wegańskie'],
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
ingredients: [
{ ingredientId: 'makaron_suchy', amount: 120, unit: 'g' },
{ ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' },
{ ingredientId: 'bazylia_swieza', amount: 10, unit: 'g' },
],
steps: [
'Ugotuj makaron al dente wg instrukcji na opakowaniu.',
'Na patelni rozgrzej oliwę, dodaj pomidory krojone i gotuj 10 min.',
'Dopraw solą, pieprzem i szczyptą cukru.',
'Wymieszaj makaron z sosem, udekoruj świeżą bazylią.',
],
},
koktajl: {
id: 'koktajl',
title: 'Koktajl owocowy',
description: 'Mix jagód i jogurtu — szybka przekąska lub drugie śniadanie.',
minutes: 5, minutes: 5,
thumbLabel: 'Koktajl', thumbLabel: 'Parmeńska',
allowedSlots: ['przekaska', 'drugie_sniadanie'], image: 'images/recipes/kanapka_parmenska.jpg',
tags: ['wegetariańskie', 'szybkie'], allowedSlots: ['sniadanie', 'drugie_sniadanie', 'kolacja'],
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 }, tags: ['szybkie'],
nutritionPerServing: { kcal: 606, protein: 47, fat: 29, carbs: 39 },
ingredients: [ ingredients: [
{ ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' }, { ingredientId: 'bulka_grahamka', amount: 1, unit: 'szt.' },
{ ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' }, { ingredientId: 'szynka_parmenska', amount: 95, unit: 'g' },
{ ingredientId: 'miod', amount: 15, unit: 'g' }, { ingredientId: 'mozzarella', amount: 60, unit: 'g' },
{ ingredientId: 'pomidorki_koktajlowe', amount: 100, unit: 'g' },
], ],
steps: [ steps: [
'Wrzuć jogurt, jagody i miód do blendera.', 'Bułkę grahamkę przekrój na pół.',
'Zmiksuj na gładką masę (~30 sekund).', 'Na bułce ułóż plastry szynki parmeńskiej, na nią pokrojoną mozzarellę.',
'Przelej do szklanki. Gotowe!', 'Podawaj z pomidorkami koktajlowymi.',
], ],
}, },
tost_awokado: { makaron_ricotta: {
id: 'tost_awokado', id: 'makaron_ricotta',
title: 'Tost z awokado', title: 'Makaron z ricottą i pomidorami',
description: 'Chleb na zakwasie z rozgniecionym awokado i cytryną.', description: 'Makaron z sosem z pieczonych pomidorków koktajlowych, ricottą i słonecznikiem.',
minutes: 10,
thumbLabel: 'Tost',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
tags: ['wegetariańskie', 'wegańskie', 'szybkie'],
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
ingredients: [
{ ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' },
{ ingredientId: 'awokado', amount: 1, unit: 'szt.' },
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
],
steps: [
'Opiecz kromki chleba w tosterze lub na suchej patelni.',
'Przekrój awokado, wyjmij pestkę i wyłóż miąższ do miseczki.',
'Rozgnieć widelcem, dodaj sok z cytryny, sól i pieprz.',
'Nałóż masę na tosty. Podawaj od razu.',
],
},
losos: {
id: 'losos',
title: 'Grillowany łosoś',
description: 'Świeży łosoś z masłem cytrynowym i koperkiem.',
minutes: 25,
thumbLabel: 'Łosoś',
allowedSlots: ['kolacja', 'obiad'],
tags: ['wysokobiałkowe'],
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
ingredients: [
{ ingredientId: 'losos_filet', amount: 180, unit: 'g' },
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
],
steps: [
'Filety oprósz solą, pieprzem i skrop sokiem z cytryny.',
'Rozgrzej patelnię grillową na dość mocnym ogniu.',
'Smaż łososia 45 min z każdej strony (skórą do dołu na start).',
'Podawaj z posiekanym koperkiem i plasterkiem cytryny.',
],
},
tacos: {
id: 'tacos',
title: 'Tacos z wołowiną',
description: 'Pikantna mielona wołowina ze świeżą salsą w tortillach.',
minutes: 20, minutes: 20,
thumbLabel: 'Tacos', thumbLabel: 'Ricotta',
allowedSlots: ['kolacja', 'obiad'], image: 'images/recipes/makaron_ricotta.jpg',
tags: [], allowedSlots: ['obiad', 'kolacja'],
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 }, tags: ['wegetariańskie'],
nutritionPerServing: { kcal: 608, protein: 24, fat: 24, carbs: 75 },
ingredients: [ ingredients: [
{ ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' }, { ingredientId: 'makaron_suchy', amount: 80, unit: 'g' },
{ ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' }, { ingredientId: 'pomidorki_koktajlowe', amount: 200, unit: 'g' },
{ ingredientId: 'salsa_pomidorowa', amount: 100, unit: 'g' }, { ingredientId: 'czosnek', amount: 6, unit: 'g' },
{ ingredientId: 'tymianek', amount: 1, unit: 'g' },
{ ingredientId: 'oliwa', amount: 5, unit: 'ml' },
{ ingredientId: 'ricotta', amount: 75, unit: 'g' },
{ ingredientId: 'bazylia_swieza', amount: 3, unit: 'g' },
{ ingredientId: 'nasiona_slonecznika', amount: 15, unit: 'g' },
], ],
steps: [ steps: [
'Na rozgrzanej patelni podsmaż mielone, rozbijając widelcem, aż się zarumieni.', 'Makaron ugotuj wg przepisu na opakowaniu. Po ugotowaniu pozostaw 12 łyżki wody.',
'Dopraw kuminem, papryką, solą i pieprzem.', 'Na patelni rozgrzej oliwę, dodaj przeciśnięty czosnek, przekrojone na pół pomidorki i tymianek — podsmażaj 23 minuty.',
'Podgrzej tortille na suchej patelni po 15 sek. z każdej strony.', 'Na patelnię z sosem dodaj ugotowany makaron. Wymieszaj, przypraw solą i pieprzem.',
'Nałóż mięso na tortillę, polej salsą i zawiń.', 'Ricottę wymieszaj z 12 łyżkami wody z gotowania makaronu. Nałóż na makaron.',
'Posyp bazylią świeżą i nasionami słonecznika.',
], ],
}, },
owsianka: { jajecznica: {
id: 'owsianka', id: 'jajecznica',
title: 'Miska owsianki', title: 'Jajecznica z pieczywem',
description: 'Ciepła owsianka z miodem — szybki i sycący posiłek.', description: 'Klasyczna jajecznica z 4 jajek z bułką grahamką i szczypiorkiem.',
minutes: 10, minutes: 5,
thumbLabel: 'Owsianka', thumbLabel: 'Jajecznica',
allowedSlots: ['sniadanie', 'drugie_sniadanie'], image: 'images/recipes/jajecznica.png',
tags: ['wegetariańskie', 'szybkie'], allowedSlots: ['sniadanie', 'drugie_sniadanie', 'kolacja'],
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 }, tags: ['szybkie', 'wysokobiałkowe'],
nutritionPerServing: { kcal: 548, protein: 36, fat: 28, carbs: 36 },
ingredients: [ ingredients: [
{ ingredientId: 'platki_owsiane', amount: 60, unit: 'g' }, { ingredientId: 'jajko', amount: 4, unit: 'szt.' },
{ ingredientId: 'mleko', amount: 200, unit: 'ml' }, { ingredientId: 'oliwa', amount: 5, unit: 'ml' },
{ ingredientId: 'miod', amount: 20, unit: 'g' }, { ingredientId: 'bulka_grahamka', amount: 1, unit: 'szt.' },
{ ingredientId: 'szczypiorek', amount: 5, unit: 'g' },
], ],
steps: [ steps: [
'W garnuszku zagotuj mleko.', 'Na patelni rozgrzej oliwę na małej mocy palnika.',
'Wsyp płatki owsiane, zmniejsz ogień i gotuj 34 min, mieszając.', 'Wbij jajka bezpośrednio na patelnię. Smaż bez przykrycia, delikatnie mieszając łopatką, aż żółtka i białka stopniowo się połączą.',
'Przełóż do miski, polej miodem. Opcjonalnie dodaj owoce lub orzechy.', 'Dopraw solą i pieprzem. Kontynuuj smażenie, aż jajka się zetną, ale pozostaną lekko kremowe — ok. 34 minuty.',
'Posyp posiekanym szczypiorkiem i podawaj z bułką grahamką.',
],
},
kanapka_hummus: {
id: 'kanapka_hummus',
title: 'Kanapka z hummusem, wędliną i warzywami',
description: 'Bułka grahamka z hummusem, szynką z kurczaka i świeżymi warzywami.',
minutes: 5,
thumbLabel: 'Hummus',
image: 'images/recipes/kanapka_hummus.png',
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'kolacja'],
tags: ['szybkie', 'wysokobiałkowe'],
nutritionPerServing: { kcal: 609, protein: 46, fat: 19, carbs: 66 },
ingredients: [
{ ingredientId: 'bulka_grahamka', amount: 1, unit: 'szt.' },
{ ingredientId: 'szynka_z_kurczaka', amount: 130, unit: 'g' },
{ ingredientId: 'pomidor', amount: 80, unit: 'g' },
{ ingredientId: 'papryka_czerwona', amount: 85, unit: 'g' },
{ ingredientId: 'ogorek', amount: 75, unit: 'g' },
{ ingredientId: 'szczypiorek', amount: 20, unit: 'g' },
{ ingredientId: 'hummus', amount: 140, unit: 'g' },
],
steps: [
'Pomidora i ogórka pokrój w plastry. Paprykę pokrój w paski. Szczypiorek posiekaj.',
'Bułkę grahamkę przekrój i posmaruj hummusem.',
'Na bułce ułóż szynkę z kurczaka, pomidora, paprykę i ogórka.',
'Posyp szczypiorkiem i podawaj.',
],
},
kanapka_losos: {
id: 'kanapka_losos',
title: 'Kanapka z wędzonym łososiem',
description: 'Bułka grahamka z łososiem wędzonym, pastą chrzanowo-serową i kiełkami.',
minutes: 5,
thumbLabel: 'Łosoś',
image: 'images/recipes/kanapka_losos.jpg',
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'kolacja'],
tags: ['szybkie'],
nutritionPerServing: { kcal: 443, protein: 30, fat: 18, carbs: 39 },
ingredients: [
{ ingredientId: 'bulka_grahamka', amount: 1, unit: 'szt.' },
{ ingredientId: 'losos_wedzony', amount: 100, unit: 'g' },
{ ingredientId: 'serek_smietankowy', amount: 40, unit: 'g' },
{ ingredientId: 'chrzan', amount: 10, unit: 'g' },
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
{ ingredientId: 'kielki_rzodkiewki', amount: 5, unit: 'g' },
{ ingredientId: 'ogorek', amount: 75, unit: 'g' },
],
steps: [
'Bułkę grahamkę przekrój i podsmaż na patelni na średnim ogniu przez 23 minuty.',
'Chrzan dokładnie wymieszaj z serkiem śmietankowym.',
'Koperek drobno posiekaj. Ogórka pokrój na mniejsze kawałki.',
'Na bułce rozsmaruj pastę chrzanowo-serową. Ułóż łososia, koperek, ogórka i kiełki.',
], ],
}, },
serek_owoc: { serek_owoc: {
@@ -419,6 +415,7 @@ export const RECIPES = {
description: 'Lekki, pożywny posiłek: serek z orzechami, truskawkami i borówkami.', description: 'Lekki, pożywny posiłek: serek z orzechami, truskawkami i borówkami.',
minutes: 5, minutes: 5,
thumbLabel: 'Serek', thumbLabel: 'Serek',
image: 'images/recipes/serek_owoc.jpg',
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'], allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
tags: ['wegetariańskie', 'wysokobiałkowe', 'szybkie'], tags: ['wegetariańskie', 'wysokobiałkowe', 'szybkie'],
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 }, nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },

View File

@@ -523,8 +523,10 @@ function renderDayContent(state) {
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}"> <div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}"> <div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="w-8 h-8 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0"> <div class="w-8 h-8 rounded-lg bg-[#d4d4d4] overflow-hidden shrink-0">
<span class="text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span> ${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-[13px] font-bold text-gray-900 truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p> <p class="text-[13px] font-bold text-gray-900 truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p>
@@ -629,8 +631,10 @@ function getRecentRecipeIds(plans, limit = 5) {
function recipeCardHtml(r) { function recipeCardHtml(r) {
return ` return `
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}"> <button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
<div class="w-11 h-11 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0"> <div class="w-11 h-11 rounded-lg bg-[#d4d4d4] overflow-hidden shrink-0">
<span class="text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span> ${r.image
? `<img src="${escapeHtml(r.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>`}
</div> </div>
<div class="min-w-0 flex-1 py-0.5"> <div class="min-w-0 flex-1 py-0.5">
<p class="text-[13px] font-bold text-gray-900 line-clamp-2">${escapeHtml(r.title)}</p> <p class="text-[13px] font-bold text-gray-900 line-clamp-2">${escapeHtml(r.title)}</p>
@@ -872,9 +876,9 @@ function seedDemoIfEmpty(plans) {
return { return {
...plans, ...plans,
[todayKey]: { [todayKey]: {
sniadanie: [{ id: newPlanEntryId(), recipeId: 'owsianka', servings: 1 }], sniadanie: [{ id: newPlanEntryId(), recipeId: 'jajecznica', servings: 1 }],
obiad: [{ id: newPlanEntryId(), recipeId: 'salatka', servings: 1 }], obiad: [{ id: newPlanEntryId(), recipeId: 'makaron_ricotta', servings: 1 }],
kolacja: [{ id: newPlanEntryId(), recipeId: 'makaron', servings: 1 }], kolacja: [{ id: newPlanEntryId(), recipeId: 'kanapka_losos', servings: 1 }],
}, },
}; };
} }

View File

@@ -67,8 +67,9 @@ export function getRecipeDetailHTML() {
</div> </div>
</div> </div>
<div id="rd-hero" class="h-[220px] shrink-0 w-full bg-[#d4d4d4] flex items-center justify-center relative"> <div id="rd-hero" class="h-[220px] shrink-0 w-full bg-[#d4d4d4] relative overflow-hidden">
<span id="rd-hero-label" class="text-white font-medium text-[15px]"></span> <img id="rd-hero-img" src="" alt="" class="w-full h-full object-cover hidden">
<span id="rd-hero-label" class="absolute inset-0 flex items-center justify-center text-white font-medium text-[15px]"></span>
</div> </div>
<div class="bg-white rounded-t-3xl -mt-6 relative z-30 pt-6 flex flex-col flex-1 overflow-hidden"> <div class="bg-white rounded-t-3xl -mt-6 relative z-30 pt-6 flex flex-col flex-1 overflow-hidden">
@@ -128,7 +129,18 @@ function populateDetail(recipeId) {
currentSubstitutions = {}; currentSubstitutions = {};
expandedAlternatives.clear(); expandedAlternatives.clear();
document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`; const heroImg = document.getElementById('rd-hero-img');
const heroLabel = document.getElementById('rd-hero-label');
if (recipe.image) {
heroImg.src = recipe.image;
heroImg.alt = recipe.title;
heroImg.classList.remove('hidden');
heroLabel.textContent = '';
} else {
heroImg.classList.add('hidden');
heroImg.src = '';
heroLabel.textContent = `Zdjęcie: ${recipe.title}`;
}
document.getElementById('rd-title').textContent = recipe.title; document.getElementById('rd-title').textContent = recipe.title;
document.getElementById('rd-time').textContent = `${recipe.minutes} min`; document.getElementById('rd-time').textContent = `${recipe.minutes} min`;
updateKcalDisplay(); updateKcalDisplay();
@@ -190,10 +202,11 @@ function renderNutritionLine(nutrition) {
/* ── ingredients tab with inline nutrition + summary ───── */ /* ── ingredients tab with inline nutrition + summary ───── */
function computeIngredientNutritionTotals(recipe) { function computeEffectiveNutritionTotals(recipe) {
let kcal = 0, protein = 0, fat = 0, carbs = 0; let kcal = 0, protein = 0, fat = 0, carbs = 0;
for (const ing of recipe.ingredients) { for (const ing of recipe.ingredients) {
const n = nutritionForAmount(ing.ingredientId, ing.amount * currentServings); const effectiveId = getEffectiveIngredientId(ing.ingredientId);
const n = nutritionForAmount(effectiveId, ing.amount * currentServings);
if (n) { if (n) {
kcal += n.kcal; kcal += n.kcal;
protein += n.protein; protein += n.protein;
@@ -210,14 +223,8 @@ function computeIngredientNutritionTotals(recipe) {
} }
function renderNutritionSummary(recipe) { function renderNutritionSummary(recipe) {
const n = recipe.nutritionPerServing;
const s = currentServings; const s = currentServings;
const total = { const total = computeEffectiveNutritionTotals(recipe);
kcal: Math.round(n.kcal * s * 10) / 10,
protein: Math.round(n.protein * s * 10) / 10,
fat: Math.round(n.fat * s * 10) / 10,
carbs: Math.round(n.carbs * s * 10) / 10,
};
return ` return `
<div class="mb-4 pt-1 pb-3 border-b border-gray-100"> <div class="mb-4 pt-1 pb-3 border-b border-gray-100">
@@ -255,7 +262,7 @@ function renderIngredientCard(name, amount, unit, nutrition, extra) {
: ''; : '';
return ` return `
<div class="${extra?.cls || 'bg-white'} rounded-xl p-2.5 border ${extra?.border || 'border-gray-200'} flex items-center gap-2.5"> <div class="${extra?.cls || 'bg-white'} rounded-xl p-2.5 border ${extra?.border || 'border-gray-200'} flex items-center gap-2.5" ${extra?.dataAttrs || ''}>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
${extra?.prefix || ''} ${extra?.prefix || ''}
@@ -277,35 +284,52 @@ function renderIngredients(recipe) {
if (!container) return; if (!container) return;
const rows = recipe.ingredients.map((ing) => { const rows = recipe.ingredients.map((ing) => {
const def = INGREDIENTS[ing.ingredientId]; const origId = ing.ingredientId;
const name = def?.name || ing.ingredientId;
const scaledAmount = ing.amount * currentServings;
const nutrition = nutritionForAmount(ing.ingredientId, scaledAmount);
const hasAlts = ing.alternatives && ing.alternatives.length > 0; const hasAlts = ing.alternatives && ing.alternatives.length > 0;
const isExpanded = expandedAlternatives.has(ing.ingredientId); const effectiveId = hasAlts ? getEffectiveIngredientId(origId) : origId;
const effectiveDef = INGREDIENTS[effectiveId];
const effectiveName = effectiveDef?.name || effectiveId;
const scaledAmount = ing.amount * currentServings;
const nutrition = nutritionForAmount(effectiveId, scaledAmount);
const isSwapped = effectiveId !== origId;
const isExpanded = expandedAlternatives.has(origId);
const toggleBtn = hasAlts const toggleBtn = hasAlts
? `<button type="button" class="rd-alt-toggle shrink-0 w-5 h-5 rounded-full ${isExpanded ? 'bg-amber-50 text-amber-500' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'} flex items-center justify-center transition-colors" data-original-id="${escapeHtml(ing.ingredientId)}"><i class="fas fa-shuffle text-[8px]"></i></button>` ? `<button type="button" class="rd-alt-toggle shrink-0 w-5 h-5 rounded-full ${isExpanded ? 'bg-amber-50 text-amber-500' : isSwapped ? 'bg-amber-50 text-amber-500' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'} flex items-center justify-center transition-colors" data-original-id="${escapeHtml(origId)}"><i class="fas fa-shuffle text-[8px]"></i></button>`
: ''; : '';
const cardHtml = renderIngredientCard(name, scaledAmount, ing.unit, nutrition, { suffix: toggleBtn }); const cardBorder = isSwapped ? 'border-amber-200' : 'border-gray-200';
const cardCls = isSwapped ? 'bg-amber-50/30' : 'bg-white';
const cardHtml = renderIngredientCard(effectiveName, scaledAmount, ing.unit, nutrition, {
suffix: toggleBtn,
border: cardBorder,
cls: cardCls,
});
let altListHtml = ''; let altListHtml = '';
if (hasAlts) { if (hasAlts && isExpanded) {
const altCards = ing.alternatives.map((altId) => { const allOptions = [origId, ...ing.alternatives];
const altDef = INGREDIENTS[altId]; const optionCards = allOptions.map((altId) => {
const altName = altDef?.name || altId; const def = INGREDIENTS[altId];
const altName = def?.name || altId;
const isSelected = effectiveId === altId;
const isOriginal = altId === origId;
const altNutrition = nutritionForAmount(altId, scaledAmount); const altNutrition = nutritionForAmount(altId, scaledAmount);
const radioDot = `<div class="w-3.5 h-3.5 rounded-full border-2 shrink-0 ${isSelected ? 'border-gray-900' : 'border-gray-300'} flex items-center justify-center">${isSelected ? '<div class="w-1.5 h-1.5 rounded-full bg-gray-900"></div>' : ''}</div>`;
const defaultTag = isOriginal ? `<span class="text-[9px] px-1.5 py-0.5 rounded ${isSelected ? 'bg-gray-200 text-gray-600' : 'bg-gray-100 text-gray-400'} font-medium ml-1">Domyślny</span>` : '';
return renderIngredientCard(altName, scaledAmount, ing.unit, altNutrition, { return renderIngredientCard(altName, scaledAmount, ing.unit, altNutrition, {
cls: 'bg-gray-50', cls: isSelected ? 'bg-gray-50' : 'bg-white hover:bg-gray-50 cursor-pointer',
border: 'border-gray-100', border: isSelected ? 'border-gray-900 ring-1 ring-gray-900' : 'border-gray-100',
prefix: radioDot,
badge: defaultTag,
dataAttrs: `data-original-id="${escapeHtml(origId)}" data-alt-id="${escapeHtml(altId)}"`,
}); });
}); });
altListHtml = ` altListHtml = `
<div class="rd-alt-list ${isExpanded ? '' : 'hidden'} mt-1.5 space-y-1.5" data-original-id="${escapeHtml(ing.ingredientId)}"> <div class="mt-1.5 space-y-1.5 rd-alt-options" data-original-id="${escapeHtml(origId)}">
<p class="text-[10px] text-gray-400 font-medium mb-1 pl-1">Można zamienić na:</p> ${optionCards.join('')}
${altCards.join('')}
</div>`; </div>`;
} }
@@ -324,12 +348,23 @@ function renderIngredients(recipe) {
} else { } else {
expandedAlternatives.add(origId); expandedAlternatives.add(origId);
} }
const list = container.querySelector(`.rd-alt-list[data-original-id="${origId}"]`); renderIngredients(recipe);
if (list) list.classList.toggle('hidden'); });
btn.classList.toggle('bg-gray-100'); });
btn.classList.toggle('text-gray-400');
btn.classList.toggle('bg-amber-50'); container.querySelectorAll('.rd-alt-options').forEach((group) => {
btn.classList.toggle('text-amber-500'); group.querySelectorAll('[data-alt-id]').forEach((card) => {
card.addEventListener('click', () => {
const originalId = card.dataset.originalId;
const altId = card.dataset.altId;
if (altId === originalId) {
delete currentSubstitutions[originalId];
} else {
currentSubstitutions[originalId] = altId;
}
expandedAlternatives.delete(originalId);
renderIngredients(recipe);
});
}); });
}); });
} }
@@ -625,7 +660,6 @@ export function setupRecipeDetail() {
if (!recipe) return; if (!recipe) return;
document.getElementById('rd-planner-recipe-name').textContent = recipe.title; document.getElementById('rd-planner-recipe-name').textContent = recipe.title;
currentSubstitutions = {};
expandedVariantGroups.clear(); expandedVariantGroups.clear();
const today = startOfDay(new Date()); const today = startOfDay(new Date());

View File

@@ -55,8 +55,10 @@ function renderRecipeCard(recipe) {
const labels = slotLabelsFor(recipe); const labels = slotLabelsFor(recipe);
return ` return `
<div onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow"> <div onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center"> <div class="h-32 bg-[#d4d4d4] relative overflow-hidden">
<span class="text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span> ${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">`
: `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
</div> </div>
<div class="p-3 flex flex-col flex-1"> <div class="p-3 flex flex-col flex-1">
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">${escapeHtml(recipe.title)}</h3> <h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">${escapeHtml(recipe.title)}</h3>