Reorganise adding recipe to plan
Some checks failed
Build and Deploy / build-and-push (push) Failing after 21s

This commit is contained in:
2026-03-27 15:20:46 +01:00
parent a22bb7b35c
commit 5c17976732
5 changed files with 551 additions and 292 deletions

View File

@@ -12,108 +12,154 @@
|-------|------| |-------|------|
| Liczba osób | 1 (gotuje dla siebie) | | Liczba osób | 1 (gotuje dla siebie) |
| Planowanie | Elastyczne, 17 dni do przodu | | Planowanie | Elastyczne, 17 dni do przodu |
| Posiłki | 3 główne (śniadanie, obiad, kolacja) + dodatkowe przy treningu | | 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) — kopiowanie dnia kluczowe |
| Styl gotowania | Hybrydowy: trochę meal-prep, trochę na bieżąco | | Styl gotowania | Hybrydowy: trochę meal-prep, trochę na bieżąco |
| Zakupy | Mieszane: duże zakupy + uzupełnienia w tygodniu | | Zakupy | Mieszane: duże zakupy + uzupełnienia w tygodniu |
| Cel dietetyczny | Utrzymanie wagi, śledzenie per posiłek | | Cel dietetyczny | Utrzymanie wagi, śledzenie per posiłek |
| Pomijanie posiłków | Jawne "pomijam" (jedzenie na mieście) | | Pomijanie posiłków | Jawne "Pomijam" (jedzenie na mieście itp.) |
| Przepisy | Na razie katalog wbudowany (9 przepisów), bez edytora | | Przepisy | Katalog wbudowany (9 przepisów, 24 składniki), bez edytora |
### Kluczowe pain-pointy (zgłoszone)
1. Brak możliwości pominięcia slotu w planerze
2. Brak "Dodaj do planera" z widoku przepisu
3. Ilości na liście zakupów nie do edycji
4. Brak kalorii per slot w widoku dnia
5. Za dużo kliknięć w kluczowych flow
--- ---
## 2. Przegląd widoków ## 2. Architektura i stos technologiczny
### 2.1 Przepisy (`RecipeList`) | Warstwa | Technologia |
|---------|-------------|
| Frontend | Plain HTML + ES modules, imperatywna manipulacja DOM |
| Style | Tailwind CSS (CDN) + inline `<style>` w `index.html`, Font Awesome 6 (CDN) |
| Dane | Statyczny katalog `js/data/catalog.js` (`INGREDIENTS` + `RECIPES` z opcjonalnymi `alternatives` per składnik), localStorage |
| Stan | Moduły-closure (`filterState`, `state` w planerze) + localStorage przez serwisy |
| Komunikacja widoków | Globalne callbacki na `window` (`refreshPlanner`, `refreshPantry`, `refreshShopping`, `openRecipeDetail` itp.) |
| PWA | `manifest.webmanifest` + `sw.js` (network-only fetch, spełnia warunek instalowalności) |
| Deploy | Docker + nginx:alpine, Gitea CI/CD |
| Język UI | Polski |
### Struktura plików
```
index.html ← jedyny plik HTML, shell aplikacji
js/
app.js ← entry point, montuje widoki, setupTabs()
storageKeys.js ← klucze localStorage
views/
RecipeList.js ← lista przepisów
Filter.js ← overlay filtrów
RecipeDetail.js ← detal przepisu (slide-in overlay)
MealPlanner.js ← planer posiłków + kalendarz
Pantry.js ← spiżarnia
Shopping.js ← listy zakupów
services/
planStore.js ← load/save planów posiłków
pantryShopping.js ← logika spiżarni i list zakupów
planIngredients.js ← analityka: sumy kalorii, braki, prognoza
dateUtils.js ← narzędzia dat (poniedziałek jako start tygodnia)
planner/
mealSlots.js ← definicja 5 slotów (id, label, icon)
data/
catalog.js ← INGREDIENTS, RECIPES, helpery
ui/
toast.js ← showAppToast()
```
---
## 3. Przegląd widoków
### 3.1 Przepisy (`RecipeList`)
**Lokalizacja:** `js/views/RecipeList.js` **Lokalizacja:** `js/views/RecipeList.js`
Siatka 2-kolumnowa kart przepisów generowana dynamicznie z `RECIPES`. Każda karta zawiera miniaturę (placeholder), tytuł, czas przygotowania, kalorie. Kliknięcie otwiera detal. 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.
**Elementy:** **Elementy:**
- Wyszukiwarka (real-time, po tytule i tagach) - Wyszukiwarka (real-time, po tytule i tagach)
- Przycisk filtrów (overlay) - Przycisk filtrów (otwiera overlay)
- Siatka kart - Siatka kart
- Empty state gdy brak wyników
**Stan:** Kompletny. **Stan:** Kompletny.
--- ---
### 2.2 Filtry (`Filter`) ### 3.2 Filtry (`Filter`)
**Lokalizacja:** `js/views/Filter.js` **Lokalizacja:** `js/views/Filter.js`
Overlay z chipami pór posiłku, tagami dietetycznymi i suwakiem czasu. Dynamicznie generowane z `MEAL_SLOTS` i tagów z `RECIPES`. 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:** **Elementy:**
- Chipy: pory posiłku (z `MEAL_SLOTS`) - Chipy: pory posiłku (z `MEAL_SLOTS`)
- Chipy: tagi dietetyczne (zbierane z `RECIPES`) - Chipy: tagi dietetyczne (zbierane dynamicznie z `RECIPES`)
- Suwak: maksymalny czas przygotowania - Suwak: maksymalny czas przygotowania (5120 min)
- Przycisk "Wyczyść" + "Pokaż X wyników" - Przycisk "Wyczyść" + "Pokaż X wyników"
**Stan:** Kompletny. **Stan:** Kompletny.
--- ---
### 2.3 Szczegóły przepisu (`RecipeDetail`) ### 3.3 Szczegóły przepisu (`RecipeDetail`)
**Lokalizacja:** `js/views/RecipeDetail.js` **Lokalizacja:** `js/views/RecipeDetail.js`
Full-screen overlay z detalami przepisu. Trzy zakładki: Składniki, Kroki, Wartości. Slide-in overlay z detalami przepisu. Trzy zakładki: Składniki, Kroki, Wartości.
**Elementy:** **Elementy:**
- Hero (placeholder) + strzałka powrotu + **"Do planera"** (nowe) - Hero (placeholder) + strzałka powrotu + przycisk "Zaplanuj"
- Tytuł, tagi, czas, kcal - Tytuł, tagi (sloty + tagi przepisu), czas, kcal
- Selektor porcji (±) z przeliczaniem - Selektor porcji (± , zakres 112) z przeliczaniem składników i wartości
- Zakładka Składniki: lista z checkboxami + badge stanu spiżarni + "Dodaj do listy zakupów" - 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 Kroki: numerowane kroki
- Zakładka Wartości: kcal/białko/tłuszcze/węglowodany × porcje - Zakładka Wartości: kcal/białko/tłuszcze/węglowodany × porcje
- **Bottom sheet "Dodaj do planera"**: wybór dnia (7 dni) + slotu → zapis do planStore - 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:**
- W `RECIPES`, składnik może mieć pole `alternatives: ['id1', 'id2', ...]` — tablica ID alternatywnych składników
- Przy dodawaniu do planera, wybrane zamienniki zapisywane są jako `substitutions: { originalId: chosenAltId }` w `planStore`
- Przykład: serek wiejski ma 3 wymienne składniki — orzechy (5 opcji), truskawki (banany), borówki (jagody)
**Stan:** Kompletny. **Stan:** Kompletny.
--- ---
### 2.4 Planer posiłków (`MealPlanner`) ### 3.4 Planer posiłków (`MealPlanner`)
**Lokalizacja:** `js/views/MealPlanner.js` **Lokalizacja:** `js/views/MealPlanner.js`
Kalendarz (tydzień/miesiąc) + plan dnia z slotami posiłków. Kalendarz (tydzień/miesiąc) + plan dnia z 5 slotami posiłków.
**Elementy kalendarza:** **Elementy kalendarza:**
- Widok tygodnia ↔ miesiąca (swipe góra/dół) - Widok tygodnia ↔ miesiąca (swipe góra/dół na `#calendar-swipe-zone`)
- Nawigacja: ←/→ + "Dziś" - Nawigacja: ←/→ + "Dziś"
- Kropki pod dniem = zaplanowane posiłki - Kropki pod dniem = zaplanowane posiłki
**Elementy planu dnia:** **Elementy planu dnia:**
- Nagłówek dnia + **"Kopiuj dzień"** (nowe) - Nagłówek dnia + przycisk "Kopiuj dzień"
- Karta podsumowania kalorycznego (kcal + makro, rozwijalne szczegóły) - Karta podsumowania kalorycznego (kcal + makro, rozwijalne szczegóły)
- "Składniki na ten dzień" (badge z liczbą braków) - "Składniki na ten dzień" (badge z liczbą braków vs "OK")
- Sloty posiłków (5 slotów z `MEAL_SLOTS`): - Sloty posiłków (5 slotów z `MEAL_SLOTS`):
- **Kcal per slot** w nagłówku (nowe) - Kcal per slot w nagłówku
- Karty przepisów z porcjami, kcal, czasem, przyciskiem usuwania - Karty przepisów z porcjami (±), kcal, czasem, przyciskiem usuwania
- Kliknięcie nazwy przepisu → RecipeDetail - Kliknięcie nazwy przepisu → `RecipeDetail`
- "Dodaj przepis" / "Dodaj kolejny" - "Dodaj przepis" / "Dodaj kolejny"
- **"Pomijam"** przy pustym slocie (nowe) → slot przygaszony z "Cofnij" - "Pomijam" przy pustym slocie → slot przygaszony z "Cofnij"
**Bottom sheety:** **Bottom sheety:**
1. **Picker przepisów**: wyszukiwarka + "Ostatnio używane" + lista filtrowana do `allowedSlots` 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 tygodnia + "Dodaj braki" 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ń 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. **Stan:** Kompletny.
--- ---
### 2.5 Spiżarnia (`Pantry`) ### 3.5 Spiżarnia (`Pantry`)
**Lokalizacja:** `js/views/Pantry.js` **Lokalizacja:** `js/views/Pantry.js`
@@ -121,45 +167,43 @@ Przeglądanie i edycja stanów magazynowych składników.
**Elementy:** **Elementy:**
- Wyszukiwarka - Wyszukiwarka
- Chipy filtrów kategorii - Chipy filtrów kategorii (multi-select)
- Toggle "Tylko na stanie" - Toggle "Tylko na stanie"
- Siatka chipów składników (kolorowana wg stanu) - Siatka chipów składników pogrupowana po kategorii (kolor wg stanu)
- Bottom sheet edycji: ±, input, wartości odżywcze, "Dodaj na listę zakupów" - 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. **Stan:** Kompletny.
--- ---
### 2.6 Zakupy (`Shopping`) ### 3.6 Zakupy (`Shopping`)
**Lokalizacja:** `js/views/Shopping.js` **Lokalizacja:** `js/views/Shopping.js`
Zarządzanie listami zakupów. Zarządzanie listami zakupów — jedna stała lista kuchenna + dowolna liczba list freeform.
**Elementy:** **Elementy:**
- Selektor aktywnej listy - Selektor aktywnej listy (dropdown)
- Przycisk "Nowa lista" - Przycisk "Nowa lista" (freeform) + usuwanie list (nie dotyczy kuchennej)
- **Pasek akcji listy kuchennej** (widoczny gdy są zaznaczone pozycje): - **Lista kuchenna** (`KITCHEN_LIST_ID`): pogrupowana po kategorii, checkbox, edycja ilości (klik → prompt), usuwanie pozycji
- "Kupione → spiżarnia" (z **potwierdzeniem** — nowe) - **Pasek akcji** (widoczny gdy są zaznaczone pozycje): "Kupione → spiżarnia" (z potwierdzeniem i podglądem) + "Wyczyść kupione"
- "Wyczyść kupione" - **Lista freeform**: pozycje tekstowe z opcjonalną notatką, checkbox
- Lista kuchenna: pogrupowana po kategorii, checkbox, **edycja ilości** (klik → prompt, nowe)
- Lista freeform: tekst + notatka, checkbox
**Stan:** Kompletny. **Stan:** Kompletny.
--- ---
## 3. Przepływy między widokami ## 4. Przepływy między widokami
``` ```
Przepisy ──[klik kartę]──→ Szczegóły przepisu Przepisy ──[klik kartę]──→ Szczegóły przepisu
──[Dodaj do listy zakupów]──→ Zakupy (lista kuchenna) ──[Zaplanuj]──→ Bottom sheet (kalendarz + pora + opcjonalnie wymiana składników) → Planer
└──[Do planera]──→ Planer (wybór dnia + slotu)
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ą ──[Składniki na ten dzień]──→ Sheet: porównanie z spiżarnią + prognoza
──[Dodaj braki do listy]──→ Zakupy (lista kuchenna) ──[Dodaj braki do listy]──→ Zakupy (lista kuchenna)
──[Kopiuj dzień]──→ Sheet: wybór dnia docelowego ──[Kopiuj dzień]──→ Sheet: wybór dnia docelowego
──[Dodaj przepis]──→ Sheet: picker przepisów
Spiżarnia ──[Dodaj na listę zakupów]──→ Zakupy Spiżarnia ──[Dodaj na listę zakupów]──→ Zakupy
@@ -168,7 +212,7 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
--- ---
## 4. Scenariusze użytkownika ## 5. Scenariusze użytkownika
### Scenariusz 1: Przeglądanie przepisów ### Scenariusz 1: Przeglądanie przepisów
@@ -176,16 +220,18 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
1. Otwiera aplikację → widzi zakładkę **Przepisy** z siatką 9 kart 1. Otwiera aplikację → widzi zakładkę **Przepisy** z siatką 9 kart
2. Przewija listę, czyta opisy i kalorie na kartach 2. Przewija listę, czyta opisy i kalorie na kartach
3. Klika kartę "Makaron z pomidorami i bazylią" 3. Klika kartę "Serek wiejski z orzechami i owocami"
4. Widzi detal: składniki, kroki, wartości odżywcze 4. Widzi detal: składniki, kroki, wartości odżywcze
5. Zmienia liczbę porcji z 1 na 3 → składniki i kcal się przeliczają 5. Przy orzechach, truskawkach i borówkach widzi ikonę shuffle — klika ją przy orzechach
6. Przełącza zakładkę na "Kroki" → widzi kroki 6. Rozwija się lista alternatyw (laskowe, nerkowca, migdały, pekan) z wartościami odżywczymi — informacyjnie
7. Przełącza na "Wartości" → widzi makroskładniki ×3 7. Zmienia liczbę porcji z 1 na 2 → składniki i kcal się przeliczają
8. Wraca strzałką ← do listy 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
**Znane problemy:** **Uwagi:**
- Brak zdjęć (szare placeholdery) — OK dla prototypu - Brak zdjęć (szare placeholdery) — OK dla prototypu
- Po powrocie z detalu filtr/szukajka powinny się utrzymać (utrzymują się) - Po powrocie z detalu filtr/szukajka się utrzymują
--- ---
@@ -195,17 +241,13 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
1. Wpisuje "łosoś" → lista filtruje się do 1 karty 1. Wpisuje "łosoś" → lista filtruje się do 1 karty
2. Kasuje tekst → wracają wszystkie 2. Kasuje tekst → wracają wszystkie
3. Klika ikonę filtrów 3. Klika ikonę filtrów → otwiera się overlay
4. Zaznacza "Kolacja" → lista się zawęża 4. Zaznacza "Kolacja" → aktualizuje się licznik wyników
5. Dodatkowo zaznacza tag "Wysokobiałkowe" 5. Dodatkowo zaznacza tag "Wysokobiałkowe"
6. Ustawia suwak na max 25 min 6. Ustawia suwak na max 25 min
7. Klika "Pokaż X wyników" → wraca do przefiltrowanej listy 7. Klika "Pokaż X wyników" → wraca do przefiltrowanej listy
8. Wybiera przepis i otwiera detal 8. Wybiera przepis i otwiera detal
**Znane problemy:**
- Brak wizualnego wskaźnika aktywnych filtrów na ikonce (badge/kropka)
- Po zamknięciu filtrów ← (zamiast "Pokaż") — zachowanie może być nieintuicyjne
--- ---
### Scenariusz 3: Planowanie posiłków na tydzień ### Scenariusz 3: Planowanie posiłków na tydzień
@@ -213,21 +255,17 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
**Cel:** Użytkownik układa menu na kilka dni. **Cel:** Użytkownik układa menu na kilka dni.
1. Przechodzi na zakładkę **Planer** 1. Przechodzi na zakładkę **Planer**
2. Widzi dzisiejszy dzień z demo-danymi 2. Widzi dzisiejszy dzień (demo-dane lub wcześniej zaplanowane)
3. Klika na przepis w slocie → otwiera się detal 3. Klika na przepis w slocie → otwiera się detal
4. Wraca ← do planera 4. Wraca ← do planera
5. Klika następny dzień w kalendarzu 5. Klika następny dzień w kalendarzu
6. Widzi puste sloty, klika "Dodaj przepis" przy Śniadaniu 6. Widzi puste sloty, klika "Dodaj przepis" przy Śniadaniu
7. Picker: wpisuje fragment nazwy, widzi "Ostatnio używane" 7. Picker: wpisuje fragment nazwy, widzi "Ostatnio używane"
8. Wybiera przepis → pojawia się w slocie z **kcal w nagłówku** 8. Wybiera przepis → pojawia się w slocie z kcal w nagłówku
9. Przy obiedzie klika **"Pomijam"** (je na mieście) → slot przygaszony 9. Przy obiedzie klika "Pomijam" (je na mieście) → slot przygaszony
10. Klika "Składniki na ten dzień" → widzi braki vs spiżarnia 10. Klika "Składniki na ten dzień" → widzi braki vs spiżarnia + prognoza
11. Klika "Dodaj braki na dziś do listy" → toast 11. Klika "Dodaj braki na dziś do listy" → toast potwierdzenia
12. Następnego dnia: **"Kopiuj dzień"** → wybiera dzień docelowy → gotowe 12. Następnego dnia: "Kopiuj dzień" → wybiera dzień docelowy → gotowe
**Znane problemy:**
- Po dodaniu braków brak informacji "już dodano" — ryzyko duplikacji
- "Kopiuj dzień" kopiuje też status "Pominięto"
--- ---
@@ -239,14 +277,10 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
2. Widzi chipy składników pogrupowane po kategorii 2. Widzi chipy składników pogrupowane po kategorii
3. Włącza "Tylko na stanie" → widzi co ma 3. Włącza "Tylko na stanie" → widzi co ma
4. Klika "Płatki owsiane" → bottom sheet 4. Klika "Płatki owsiane" → bottom sheet
5. Ustawia 500g 5. Ustawia 500g (przyciskami ± lub inputem)
6. Zamyka → chip zmienił się na zielony z "500 g" 6. Zamyka → chip zmienił się na zielony z "500 g"
7. Chce dodać mleko na listę → klika "Dodaj na listę" w sheecie 7. Chce dodać mleko na listę → klika "Dodaj na listę" w sheecie
**Znane problemy:**
- Po zamknięciu bottom sheet scroll wraca na górę (re-render)
- Brak możliwości ręcznego wpisania ilości szybko (ale jest input type=number)
--- ---
### Scenariusz 5: Zakupy w sklepie ### Scenariusz 5: Zakupy w sklepie
@@ -256,15 +290,10 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
1. Otwiera zakładkę **Zakupy** 1. Otwiera zakładkę **Zakupy**
2. Widzi listę kuchenną pogrupowaną po kategorii 2. Widzi listę kuchenną pogrupowaną po kategorii
3. Bierze mleko z półki → klika checkbox → przekreślenie 3. Bierze mleko z półki → klika checkbox → przekreślenie
4. Widzi `sourceNote`: "Braki z planu dnia" 4. Widzi `sourceNote` z informacją skąd pozycja pochodzi
5. Ilość się nie zgadza — klika na **ilość** → prompt → poprawia 5. Ilość się nie zgadza — klika na ilość → prompt → poprawia
6. Kupuje dalsze pozycje 6. Kupuje dalsze pozycje
**Znane problemy:**
- Kupione mieszają się z niekupionymi w grupie (brak separacji)
- Brak podsumowania: "Do kupienia: 5, kupione: 3"
- Na telefonie w sklepie — elementy mogłyby być większe
--- ---
### Scenariusz 6: Po zakupach — przeniesienie do spiżarni ### Scenariusz 6: Po zakupach — przeniesienie do spiżarni
@@ -272,16 +301,13 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
**Cel:** Użytkownik wrócił ze sklepu, aktualizuje spiżarnię. **Cel:** Użytkownik wrócił ze sklepu, aktualizuje spiżarnię.
1. Otwiera **Zakupy** → widzi zaznaczone pozycje 1. Otwiera **Zakupy** → widzi zaznaczone pozycje
2. Pojawia się pasek: **"Kupione → spiżarnia"** i **"Wyczyść kupione"** 2. Pojawia się pasek: "Kupione → spiżarnia" i "Wyczyść kupione"
3. Klika "Kupione → spiżarnia" → **potwierdzenie z podglądem** pozycji 3. Klika "Kupione → spiżarnia" → potwierdzenie z podglądem pozycji
4. Potwierdza → toast "Przeniesiono X pozycji" 4. Potwierdza → toast "Przeniesiono X pozycji"
5. Kupione znikają z listy 5. Kupione znikają z listy
6. Przechodzi do **Spiżarni** → stany zaktualizowane 6. Przechodzi do **Spiżarni** → stany zaktualizowane
7. Wraca do **Planera** → braki zmniejszone 7. Wraca do **Planera** → braki zmniejszone
**Znane problemy:**
- Brak undo (cofnięcia przeniesienia)
--- ---
### Scenariusz 7: Gotowanie z przepisu ### Scenariusz 7: Gotowanie z przepisu
@@ -295,14 +321,25 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
5. Sprawdza ilość składnika → przełącza na "Składniki" 5. Sprawdza ilość składnika → przełącza na "Składniki"
6. Wraca na "Kroki" 6. Wraca na "Kroki"
**Znane problemy:** ---
- Brak trybu "krok po kroku" (full-screen, jeden krok, swipe next)
- Ekran wygasa po chwili (brak wake lock) ### Scenariusz 8: Dodanie przepisu do planera z widoku detalu
- Nie ma checkboxa przy krokach
**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 8: "Co mogę ugotować?" ### Scenariusz 9: "Co mogę ugotować?"
**Cel:** Użytkownik ma coś w spiżarni, chce wiedzieć co da się z tego zrobić. **Cel:** Użytkownik ma coś w spiżarni, chce wiedzieć co da się z tego zrobić.
@@ -314,44 +351,26 @@ Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
--- ---
## 5. Tabela znanych problemów i luk ## 6. Znane problemy i propozycje ulepszeń
| # | Problem | Scenariusz | Status | ### Do poprawy (TODO)
|---|---------|-----------|--------|
| 1 | Brak wskaźnika aktywnych filtrów na ikonce | 2 | TODO |
| 2 | Podwójne dodanie braków do listy zakupów | 3 | TODO |
| 3 | Kupione mieszają się z niekupionymi (brak separacji) | 5 | TODO |
| 4 | Brak podsumowania na liście zakupów (ile kupione/do kupienia) | 5 | TODO |
| 5 | Brak undo przy "Kupione → spiżarnia" | 6 | TODO |
| 6 | Brak trybu "krok po kroku" przy gotowaniu | 7 | PROPOZYCJA |
| 7 | Brak wake lock (ekran wygasa) | 7 | PROPOZYCJA |
| 8 | Brak filtra "Mam składniki" | 8 | PROPOZYCJA |
| 9 | Scroll spiżarni resetuje się po edycji | 4 | TODO |
| 10 | Zamknięcie filtrów ← vs "Pokaż" — niespójne zachowanie? | 2 | DO WERYFIKACJI |
--- | # | Problem | Dotyczy scenariusza |
|---|---------|---------------------|
| 1 | Brak wskaźnika aktywnych filtrów na ikonce (badge/kropka) | 2 |
| 2 | Po dodaniu braków do listy zakupów brak ochrony przed duplikacją (brak info "już dodano") | 3 |
| 3 | Kupione pozycje mieszają się z niekupionymi w tej samej grupie (brak separacji) | 5 |
| 4 | Brak podsumowania na liście zakupów ("Do kupienia: X, kupione: Y") | 5 |
| 5 | Brak undo przy "Kupione → spiżarnia" | 6 |
| 6 | Scroll spiżarni resetuje się po edycji (re-render) | 4 |
| 7 | "Kopiuj dzień" kopiuje też status "Pominięto" — może nie zawsze pożądane | 3 |
## 6. Zrealizowane w ostatniej iteracji ### Propozycje nowych funkcji
| # | Funkcja | Widok | | # | Funkcja | Opis |
|---|---------|-------| |---|---------|------|
| P1 | "Dodaj do planera" z widoku przepisu (wybór dnia + slotu) | RecipeDetail | | P1 | Tryb "krok po kroku" przy gotowaniu | Full-screen, jeden krok, swipe next + checkbox |
| P2 | Kopiowanie planu dnia na inny dzień | MealPlanner | | P2 | Wake lock | Zapobiega wygaszeniu ekranu podczas gotowania |
| P3 | "Pomijam" w slocie planera | MealPlanner + planStore | | P3 | Filtr "Mam składniki" | W widoku Przepisy — pokaż co da się ugotować z aktualnej spiżarni |
| P4 | Kalorie per slot w nagłówku slotu | MealPlanner | | P4 | Większe elementy na liście zakupów | Ułatwienie obsługi w sklepie na telefonie |
| P5 | Edycja ilości na liście zakupów (klik → prompt) | Shopping | | P5 | Edytor przepisów | Dodawanie własnych przepisów (poza wbudowanym katalogiem) |
| P6 | Potwierdzenie "Kupione → spiżarnia" z podglądem | Shopping |
| P7 | Lepszy picker przepisów (wyszukiwarka + ostatnio używane) | MealPlanner |
---
## 7. Stos technologiczny
| Warstwa | Technologia |
|---------|-------------|
| Frontend | Plain HTML + ES modules, imperatywna manipulacja DOM |
| Style | Tailwind CSS (CDN), Font Awesome 6 (CDN) |
| Dane | Statyczny katalog `js/data/catalog.js`, localStorage |
| PWA | manifest.webmanifest + sw.js |
| Deploy | Docker + nginx:alpine, Gitea CI/CD |
| Język UI | Polski |

View File

@@ -1,53 +0,0 @@
/* Slider styling */
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #111827;
cursor: pointer;
margin-top: -8px;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: #e5e7eb;
border-radius: 2px;
}
/* View Transitions */
.view-transition {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.slide-in {
transform: translateX(0);
opacity: 1;
}
.slide-out {
transform: translateX(100%);
opacity: 0;
pointer-events: none;
}
/* Ingredient Active States */
.ingredient-active .check-box {
background-color: #111827;
border-color: #111827;
}
.ingredient-active .check-icon {
display: block;
}
.ingredient-active .ingredient-text {
text-decoration: line-through;
color: #9ca3af;
}
/* Utilities */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

View File

@@ -187,6 +187,34 @@ export const INGREDIENTS = {
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 654, protein: 15, fat: 65, carbs: 14 }, nutritionPer100g: { kcal: 654, protein: 15, fat: 65, carbs: 14 },
}, },
orzechy_laskowe: {
id: 'orzechy_laskowe',
name: 'Orzechy laskowe',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 628, protein: 15, fat: 61, carbs: 17 },
},
orzechy_nerkowca: {
id: 'orzechy_nerkowca',
name: 'Orzechy nerkowca',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 553, protein: 18, fat: 44, carbs: 30 },
},
migdaly: {
id: 'migdaly',
name: 'Migdały',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 579, protein: 21, fat: 50, carbs: 22 },
},
orzechy_pekan: {
id: 'orzechy_pekan',
name: 'Orzechy pekan',
category: 'suche',
pantryUnit: 'g',
nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 },
},
truskawki: { truskawki: {
id: 'truskawki', id: 'truskawki',
name: 'Truskawki', name: 'Truskawki',
@@ -201,6 +229,20 @@ export const INGREDIENTS = {
pantryUnit: 'g', pantryUnit: 'g',
nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 }, 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 },
},
}; };
/** Porcja bazowa = 1; składniki przez ingredientId */ /** Porcja bazowa = 1; składniki przez ingredientId */
@@ -383,9 +425,9 @@ export const RECIPES = {
ingredients: [ ingredients: [
{ ingredientId: 'serek_wiejski', amount: 200, unit: 'g' }, { ingredientId: 'serek_wiejski', amount: 200, unit: 'g' },
{ ingredientId: 'miod', amount: 10, unit: 'g' }, { ingredientId: 'miod', amount: 10, unit: 'g' },
{ ingredientId: 'orzechy_wloskie', amount: 50, unit: 'g' }, { ingredientId: 'orzechy_wloskie', amount: 50, unit: 'g', alternatives: ['orzechy_laskowe', 'orzechy_nerkowca', 'migdaly', 'orzechy_pekan'] },
{ ingredientId: 'truskawki', amount: 100, unit: 'g' }, { ingredientId: 'truskawki', amount: 100, unit: 'g', alternatives: ['banany'] },
{ ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' }, { ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g', alternatives: ['jagody'] },
], ],
steps: [ steps: [
'Przełóż serek wiejski do miseczki.', 'Przełóż serek wiejski do miseczki.',

View File

@@ -28,6 +28,9 @@ export function normalizeSlotValue(v) {
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(), id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
recipeId: x.recipeId, recipeId: x.recipeId,
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)), servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
...(x.substitutions && typeof x.substitutions === 'object' && !Array.isArray(x.substitutions) && Object.keys(x.substitutions).length > 0
? { substitutions: { ...x.substitutions } }
: {}),
})); }));
} }
if (typeof v === 'object' && v.recipeId && RECIPES[v.recipeId]) { if (typeof v === 'object' && v.recipeId && RECIPES[v.recipeId]) {
@@ -35,6 +38,9 @@ export function normalizeSlotValue(v) {
id: newPlanEntryId(), id: newPlanEntryId(),
recipeId: v.recipeId, recipeId: v.recipeId,
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)), servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
...(v.substitutions && typeof v.substitutions === 'object' && !Array.isArray(v.substitutions) && Object.keys(v.substitutions).length > 0
? { substitutions: { ...v.substitutions } }
: {}),
}]; }];
} }
return []; return [];

View File

@@ -1,7 +1,6 @@
import { RECIPES, INGREDIENTS } from '../data/catalog.js'; import { RECIPES, INGREDIENTS } from '../data/catalog.js';
import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { addDays, startOfDay } from '../services/dateUtils.js'; import { addDays, addMonths, sameDay, sameMonth, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js';
import { dateKey, loadPlans, newPlanEntryId, savePlans } from '../services/planStore.js'; import { dateKey, loadPlans, newPlanEntryId, savePlans } from '../services/planStore.js';
import { showAppToast } from '../ui/toast.js'; import { showAppToast } from '../ui/toast.js';
@@ -14,6 +13,8 @@ function escapeHtml(s) {
} }
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label])); const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
const MONTHS_LONG = ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'];
const WEEKDAYS_SHORT = ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd'];
export function getRecipeDetailHTML() { export function getRecipeDetailHTML() {
return ` return `
@@ -23,19 +24,44 @@ export function getRecipeDetailHTML() {
<i class="fas fa-arrow-left text-[13px]"></i> <i class="fas fa-arrow-left text-[13px]"></i>
</button> </button>
<button id="rd-add-to-planner-btn" class="h-9 px-3 bg-white/90 backdrop-blur rounded-full flex items-center justify-center gap-1.5 shadow-sm text-gray-800 hover:bg-white transition-colors text-[12px] font-semibold"> <button id="rd-add-to-planner-btn" class="h-9 px-3 bg-white/90 backdrop-blur rounded-full flex items-center justify-center gap-1.5 shadow-sm text-gray-800 hover:bg-white transition-colors text-[12px] font-semibold">
<i class="fas fa-calendar-plus text-[11px]"></i> Do planera <i class="fas fa-calendar-plus text-[11px]"></i> Zaplanuj
</button> </button>
</div> </div>
<div id="rd-planner-picker" class="absolute inset-0 z-50 bg-black/45 hidden flex items-end" style="pointer-events: none"> <div id="rd-planner-picker" class="absolute inset-0 z-50 bg-black/45 hidden flex items-end" style="pointer-events: none">
<div id="rd-planner-sheet" class="w-full bg-white rounded-t-3xl shadow-lg p-5 pb-8 max-h-[70vh] overflow-y-auto" style="pointer-events: auto; transform: translateY(100%); transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)"> <div id="rd-planner-sheet" class="w-full bg-white rounded-t-3xl shadow-lg p-5 pb-8 max-h-[85vh] overflow-y-auto" style="pointer-events: auto; transform: translateY(100%); transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4"></div> <div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 class="text-[15px] font-bold text-gray-900 mb-1">Dodaj do planera</h3> <h3 class="text-[15px] font-bold text-gray-900 mb-1">Zaplanuj</h3>
<p id="rd-planner-recipe-name" class="text-[11px] text-gray-500 mb-4"></p> <p id="rd-planner-recipe-name" class="text-[11px] text-gray-500 mb-3"></p>
<div id="rd-planner-days" class="space-y-2 mb-4"></div>
<div class="mb-4">
<div class="flex items-center gap-1 mb-2">
<button id="rd-cal-prev" type="button" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"><i class="fas fa-chevron-left text-xs"></i></button>
<p id="rd-cal-title" class="flex-1 min-w-0 text-xs font-medium text-gray-900 text-center tabular-nums leading-none px-1 truncate"></p>
<button id="rd-cal-next" type="button" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"><i class="fas fa-chevron-right text-xs"></i></button>
</div>
<div class="flex items-center justify-center mb-2">
<button id="rd-cal-today" type="button" class="h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2.5 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors hidden">
<i class="fas fa-calendar-day text-[9px] opacity-70"></i> Dziś
</button>
</div>
<div class="grid grid-cols-7 gap-0.5 text-center text-[9px] font-medium text-gray-400 uppercase tracking-wide mb-0.5 leading-none">
${WEEKDAYS_SHORT.map((d) => `<div>${d}</div>`).join('')}
</div>
<div id="rd-cal-grid" class="grid grid-cols-7 gap-0.5"></div>
<button id="rd-cal-toggle" type="button" class="w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors">
<i id="rd-cal-toggle-icon" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p> <p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p>
<div id="rd-planner-slots" class="flex flex-wrap gap-1.5 mb-5"></div> <div id="rd-planner-slots" class="flex flex-wrap gap-1.5 mb-4"></div>
<button id="rd-planner-confirm" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold text-[13px] transition-colors flex items-center justify-center gap-2">
<div id="rd-planner-variant" class="mb-4 hidden">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Wymienne składniki</p>
<div id="rd-planner-variant-options"></div>
</div>
<button id="rd-planner-confirm" type="button" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold text-[13px] transition-colors flex items-center justify-center gap-2">
<i class="fas fa-check text-xs"></i> Dodaj <i class="fas fa-check text-xs"></i> Dodaj
</button> </button>
</div> </div>
@@ -83,8 +109,18 @@ export function getRecipeDetailHTML() {
`; `;
} }
/* ── state ─────────────────────────────────────────────── */
let currentRecipeId = null; let currentRecipeId = null;
let currentServings = 1; let currentServings = 1;
let currentSubstitutions = {};
let expandedAlternatives = new Set();
function getEffectiveIngredientId(originalId) {
return currentSubstitutions[originalId] || originalId;
}
/* ── populate ──────────────────────────────────────────── */
function populateDetail(recipeId) { function populateDetail(recipeId) {
const recipe = RECIPES[recipeId]; const recipe = RECIPES[recipeId];
@@ -92,6 +128,8 @@ function populateDetail(recipeId) {
currentRecipeId = recipeId; currentRecipeId = recipeId;
currentServings = 1; currentServings = 1;
currentSubstitutions = {};
expandedAlternatives.clear();
document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`; document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`;
document.getElementById('rd-title').textContent = recipe.title; document.getElementById('rd-title').textContent = recipe.title;
@@ -107,7 +145,6 @@ function populateDetail(recipeId) {
tagsHtml.push(`<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">${escapeHtml(tag)}</span>`); tagsHtml.push(`<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">${escapeHtml(tag)}</span>`);
} }
document.getElementById('rd-tags').innerHTML = tagsHtml.join(''); document.getElementById('rd-tags').innerHTML = tagsHtml.join('');
document.getElementById('rd-servings').textContent = '1'; document.getElementById('rd-servings').textContent = '1';
renderIngredients(recipe); renderIngredients(recipe);
@@ -127,6 +164,8 @@ function populateDetail(recipeId) {
document.getElementById('rd-tab-ingredients')?.classList.add('block'); document.getElementById('rd-tab-ingredients')?.classList.add('block');
} }
/* ── helpers ───────────────────────────────────────────── */
function updateKcalDisplay() { function updateKcalDisplay() {
const recipe = RECIPES[currentRecipeId]; const recipe = RECIPES[currentRecipeId];
if (!recipe) return; if (!recipe) return;
@@ -134,88 +173,93 @@ function updateKcalDisplay() {
document.getElementById('rd-kcal').textContent = `${kcal} kcal`; document.getElementById('rd-kcal').textContent = `${kcal} kcal`;
} }
function nutritionForAmount(ingredientId, amount) {
const def = INGREDIENTS[ingredientId];
if (!def?.nutritionPer100g) return null;
const f = amount / 100;
return {
kcal: Math.round(def.nutritionPer100g.kcal * f),
protein: Math.round(def.nutritionPer100g.protein * f * 10) / 10,
fat: Math.round(def.nutritionPer100g.fat * f * 10) / 10,
carbs: Math.round(def.nutritionPer100g.carbs * f * 10) / 10,
};
}
function renderNutritionLine(nutrition) {
if (!nutrition) return '';
return `<div class="text-[10px] text-gray-500 mt-1 flex flex-wrap gap-x-3 gap-y-0.5"><span>${nutrition.kcal} kcal</span><span>${nutrition.protein}g białko</span><span>${nutrition.fat}g tłuszcz</span><span>${nutrition.carbs}g węgle</span></div>`;
}
/* ── ingredients tab (read-only) ───────────────────────── */
function renderIngredients(recipe) { function renderIngredients(recipe) {
const container = document.getElementById('rd-tab-ingredients'); const container = document.getElementById('rd-tab-ingredients');
if (!container) return; if (!container) return;
const pantry = loadPantry();
const rows = recipe.ingredients.map((ing) => { const rows = recipe.ingredients.map((ing) => {
const def = INGREDIENTS[ing.ingredientId]; const def = INGREDIENTS[ing.ingredientId];
const name = def?.name || ing.ingredientId; const name = def?.name || ing.ingredientId;
const scaledAmount = ing.amount * currentServings; const scaledAmount = ing.amount * currentServings;
const displayAmount = Number.isInteger(scaledAmount) ? scaledAmount : parseFloat(scaledAmount.toFixed(1)); const displayAmount = Number.isInteger(scaledAmount) ? scaledAmount : parseFloat(scaledAmount.toFixed(1));
const pantryQty = Number(pantry[ing.ingredientId]) || 0; const hasAlts = ing.alternatives && ing.alternatives.length > 0;
let stockBadge = ''; const isExpanded = expandedAlternatives.has(ing.ingredientId);
if (def) {
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit; const toggleBtn = hasAlts
if (pantryQty >= scaledAmount) { ? `<button type="button" class="rd-alt-toggle shrink-0 w-6 h-6 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-[9px]"></i></button>`
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600 font-semibold whitespace-nowrap">Masz</span>`; : '';
} else if (pantryQty > 0) {
const miss = parseFloat((scaledAmount - pantryQty).toFixed(1)); let altListHtml = '';
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 font-semibold whitespace-nowrap">Brak ${miss} ${u}</span>`; if (hasAlts) {
} else { const altCards = ing.alternatives.map((altId) => {
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-red-50 text-red-500 font-semibold whitespace-nowrap">Brak</span>`; const altDef = INGREDIENTS[altId];
} const altName = escapeHtml(altDef?.name || altId);
const nutrition = nutritionForAmount(altId, scaledAmount);
return `<div class="bg-gray-50 rounded-lg p-2.5 border border-gray-100">
<p class="text-[12px] font-medium text-gray-700">${altName}</p>
${renderNutritionLine(nutrition)}
</div>`;
});
altListHtml = `
<div class="rd-alt-list ${isExpanded ? '' : 'hidden'} mt-2 mb-0.5 space-y-1.5" data-original-id="${escapeHtml(ing.ingredientId)}">
<p class="text-[10px] text-gray-400 font-medium mb-1">Można zamienić na:</p>
${altCards.join('')}
</div>`;
} }
return ` return `
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors rd-ingredient-row" data-ingredient-id="${escapeHtml(ing.ingredientId)}" data-base-amount="${ing.amount}" data-unit="${escapeHtml(ing.unit)}"> <li class="py-2.5 border-b border-gray-100">
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white rd-check-box transition-colors"><i class="fas fa-check text-[10px] hidden rd-check-icon"></i></div> <div class="flex items-center gap-2.5 py-0.5">
<span class="text-gray-700 text-[13px] flex-1 rd-ing-text transition-colors">${escapeHtml(name)}</span> <span class="text-gray-700 text-[13px] flex-1">${escapeHtml(name)}</span>
${stockBadge} ${toggleBtn}
<span class="font-medium text-gray-900 text-[13px] rd-ing-amount tabular-nums">${displayAmount} ${escapeHtml(ing.unit)}</span> <span class="font-medium text-gray-900 text-[13px] tabular-nums">${displayAmount} ${escapeHtml(ing.unit)}</span>
</div>${altListHtml}
</li>`; </li>`;
}).join(''); }).join('');
container.innerHTML = ` container.innerHTML = `
<div class="flex justify-between items-end mb-3"> <ul class="space-y-0" id="rd-ingredient-list">${rows}</ul>`;
<span class="text-[11px] text-gray-500 font-medium">Zaznacz składniki do kupienia</span>
</div>
<ul class="space-y-0 mb-5" id="rd-ingredient-list">${rows}</ul>
<button id="rd-add-to-shopping" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2 mb-5">
<i class="fas fa-plus text-xs"></i> Dodaj do listy zakupów
</button>`;
container.querySelectorAll('.rd-ingredient-row').forEach((row) => { container.querySelectorAll('.rd-alt-toggle').forEach((btn) => {
row.addEventListener('click', () => row.classList.toggle('ingredient-active')); btn.addEventListener('click', () => {
}); const origId = btn.dataset.originalId;
if (expandedAlternatives.has(origId)) {
document.getElementById('rd-add-to-shopping')?.addEventListener('click', () => { expandedAlternatives.delete(origId);
const recipe = RECIPES[currentRecipeId]; } else {
if (!recipe) return; expandedAlternatives.add(origId);
}
const checkedRows = container.querySelectorAll('.rd-ingredient-row.ingredient-active'); const list = container.querySelector(`.rd-alt-list[data-original-id="${origId}"]`);
if (checkedRows.length === 0) { if (list) list.classList.toggle('hidden');
showAppToast('Zaznacz składniki, które chcesz dodać.'); btn.classList.toggle('bg-gray-100');
return; btn.classList.toggle('text-gray-400');
} btn.classList.toggle('bg-amber-50');
btn.classList.toggle('text-amber-500');
const lines = [];
checkedRows.forEach((row) => {
const ingredientId = row.dataset.ingredientId;
const baseAmount = parseFloat(row.dataset.baseAmount);
const unit = row.dataset.unit;
const def = INGREDIENTS[ingredientId];
lines.push({
ingredientId,
amount: Math.round(baseAmount * currentServings * 100) / 100,
unit,
name: def?.name || ingredientId,
category: def?.category || 'inne',
sourceNote: `Przepis: ${recipe.title}`,
});
}); });
addOrMergeShoppingLines(lines);
showAppToast(`Dodano ${lines.length} składnik(ów) na listę zakupów.`);
window.refreshShopping?.();
checkedRows.forEach((row) => row.classList.remove('ingredient-active'));
}); });
} }
/* ── steps tab ─────────────────────────────────────────── */
function renderSteps(recipe) { function renderSteps(recipe) {
const container = document.getElementById('rd-tab-steps'); const container = document.getElementById('rd-tab-steps');
if (!container) return; if (!container) return;
@@ -236,6 +280,8 @@ function renderSteps(recipe) {
</div>`; </div>`;
} }
/* ── nutrition tab ─────────────────────────────────────── */
function renderNutrition(recipe) { function renderNutrition(recipe) {
const container = document.getElementById('rd-tab-nutrition'); const container = document.getElementById('rd-tab-nutrition');
if (!container) return; if (!container) return;
@@ -255,6 +301,8 @@ function renderNutrition(recipe) {
</div>`; </div>`;
} }
/* ── setup ─────────────────────────────────────────────── */
export function setupRecipeDetail() { export function setupRecipeDetail() {
document.querySelectorAll('.rd-tab-btn').forEach((btn) => { document.querySelectorAll('.rd-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -303,36 +351,239 @@ export function setupRecipeDetail() {
} }
}); });
const WEEKDAYS_LONG = ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota']; /* ── planner picker ──────────────────────────────── */
const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'];
let plannerPickerDay = null; let plannerPickerDay = null;
let plannerPickerSlot = null; let plannerPickerSlot = null;
let calViewDate = null;
let calExpanded = false;
const plannerOverlay = document.getElementById('rd-planner-picker'); const plannerOverlay = document.getElementById('rd-planner-picker');
const plannerSheet = document.getElementById('rd-planner-sheet'); const plannerSheet = document.getElementById('rd-planner-sheet');
function renderCalendarCell(d, today, activeMonth) {
const isToday = sameDay(d, today);
const isSelected = plannerPickerDay && sameDay(d, plannerPickerDay);
const isOtherMonth = d.getMonth() !== activeMonth;
const isPast = d.getTime() < today.getTime();
let cls = 'flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors w-full min-h-10 py-1 gap-0.5 leading-tight ';
if (isSelected) {
cls += 'bg-gray-900 text-white ';
} else if (isPast) {
cls += 'text-gray-300 ';
} else if (isOtherMonth) {
cls += 'text-gray-300 ';
} else {
cls += 'text-gray-800 hover:bg-gray-100 ';
}
if (isToday && !isSelected && !isPast) {
cls += 'ring-1 ring-inset ring-gray-900 ';
}
const inner = `<span>${d.getDate()}</span><span class="w-1 h-1"></span>`;
if (isPast && !isSelected) {
return `<div class="${cls}">${inner}</div>`;
}
return `<button type="button" class="rd-cal-day ${cls}" data-ts="${d.getTime()}">${inner}</button>`;
}
function renderPlannerCalendar() {
const grid = document.getElementById('rd-cal-grid');
const titleEl = document.getElementById('rd-cal-title');
const todayBtn = document.getElementById('rd-cal-today');
const toggleIcon = document.getElementById('rd-cal-toggle-icon');
if (!grid || !titleEl) return;
const today = startOfDay(new Date());
if (calExpanded) {
const ms = startOfMonth(calViewDate);
titleEl.textContent = `${MONTHS_LONG[ms.getMonth()]} ${ms.getFullYear()}`;
toggleIcon.className = 'fas fa-chevron-up text-[10px]';
const firstCell = startOfWeekMonday(ms);
const cells = [];
let d = new Date(firstCell);
for (let i = 0; i < 42; i++) {
cells.push(new Date(d));
d = addDays(d, 1);
}
while (cells.length > 7) {
const last7 = cells.slice(-7);
if (last7.every((c) => c.getMonth() !== ms.getMonth())) cells.splice(-7);
else break;
}
grid.innerHTML = cells.map((c) => renderCalendarCell(c, today, ms.getMonth())).join('');
todayBtn.classList.toggle('hidden', sameMonth(today, calViewDate));
} else {
const ws = startOfWeekMonday(calViewDate);
titleEl.textContent = `${MONTHS_LONG[calViewDate.getMonth()]} ${calViewDate.getFullYear()}`;
toggleIcon.className = 'fas fa-chevron-down text-[10px]';
const cells = [];
for (let i = 0; i < 7; i++) cells.push(addDays(ws, i));
grid.innerHTML = cells.map((c) => renderCalendarCell(c, today, calViewDate.getMonth())).join('');
const todayWs = startOfWeekMonday(today);
todayBtn.classList.toggle('hidden', sameDay(ws, todayWs));
}
grid.querySelectorAll('.rd-cal-day').forEach((btn) => {
btn.addEventListener('click', () => {
plannerPickerDay = new Date(Number(btn.dataset.ts));
calViewDate = new Date(plannerPickerDay);
renderPlannerCalendar();
});
});
}
document.getElementById('rd-cal-prev')?.addEventListener('click', () => {
calViewDate = calExpanded ? addMonths(calViewDate, -1) : addDays(calViewDate, -7);
renderPlannerCalendar();
});
document.getElementById('rd-cal-next')?.addEventListener('click', () => {
calViewDate = calExpanded ? addMonths(calViewDate, 1) : addDays(calViewDate, 7);
renderPlannerCalendar();
});
document.getElementById('rd-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
plannerPickerDay = today;
calViewDate = today;
renderPlannerCalendar();
});
document.getElementById('rd-cal-toggle')?.addEventListener('click', () => {
calExpanded = !calExpanded;
renderPlannerCalendar();
});
/* ── planner variant selection ────────────────────── */
const expandedVariantGroups = new Set();
function renderPlannerVariants(recipe) {
const section = document.getElementById('rd-planner-variant');
const container = document.getElementById('rd-planner-variant-options');
if (!section || !container) return;
const altsIngredients = recipe.ingredients.filter((i) => i.alternatives?.length > 0);
if (altsIngredients.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
let html = '';
for (const ing of altsIngredients) {
const origId = ing.ingredientId;
const scaledAmount = ing.amount * currentServings;
const displayAmount = Number.isInteger(scaledAmount) ? scaledAmount : parseFloat(scaledAmount.toFixed(1));
const effectiveId = getEffectiveIngredientId(origId);
const effectiveDef = INGREDIENTS[effectiveId];
const effectiveName = effectiveDef?.name || effectiveId;
const isExpanded = expandedVariantGroups.has(origId);
const isSwapped = effectiveId !== origId;
html += `<div class="mb-2 last:mb-0">
<button type="button" class="rd-variant-toggle w-full flex items-center gap-2.5 p-2.5 rounded-xl border ${isSwapped ? 'border-amber-200 bg-amber-50/50' : 'border-gray-200 bg-white'} hover:border-gray-300 transition-colors text-left" data-original-id="${escapeHtml(origId)}">
<i class="fas fa-shuffle text-[9px] ${isSwapped ? 'text-amber-500' : 'text-gray-400'}"></i>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-[12px] font-semibold text-gray-900 truncate">${escapeHtml(effectiveName)}</span>
<span class="text-[10px] text-gray-400 tabular-nums shrink-0">${displayAmount} ${escapeHtml(ing.unit)}</span>
</div>
${renderNutritionLine(nutritionForAmount(effectiveId, scaledAmount))}
</div>
<i class="fas fa-chevron-${isExpanded ? 'up' : 'down'} text-[9px] text-gray-400 shrink-0"></i>
</button>`;
if (isExpanded) {
const allOptions = [origId, ...ing.alternatives];
html += '<div class="mt-1.5 space-y-1 pl-2">';
for (const altId of allOptions) {
const def = INGREDIENTS[altId];
const altName = def?.name || altId;
const isSelected = effectiveId === altId;
const isOriginal = altId === origId;
const nutrition = nutritionForAmount(altId, scaledAmount);
const selectedCls = isSelected
? 'border-gray-900 bg-gray-50 ring-1 ring-gray-900'
: 'border-gray-200 bg-white hover:border-gray-300';
let tag = '';
if (isOriginal) tag = `<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 shrink-0">Domyślny</span>`;
html += `
<button type="button" class="rd-variant-option w-full text-left p-2.5 rounded-lg border transition-all ${selectedCls}" data-original-id="${escapeHtml(origId)}" data-alt-id="${escapeHtml(altId)}">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<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>
<span class="text-[12px] font-semibold text-gray-900 truncate">${escapeHtml(altName)}</span>
</div>
${tag}
</div>
${renderNutritionLine(nutrition)}
</button>`;
}
html += '</div>';
}
html += '</div>';
}
container.innerHTML = html;
}
document.getElementById('rd-planner-variant-options')?.addEventListener('click', (e) => {
const toggle = e.target.closest('.rd-variant-toggle');
if (toggle) {
const origId = toggle.dataset.originalId;
if (expandedVariantGroups.has(origId)) expandedVariantGroups.delete(origId);
else expandedVariantGroups.add(origId);
const recipe = RECIPES[currentRecipeId];
if (recipe) renderPlannerVariants(recipe);
return;
}
const btn = e.target.closest('.rd-variant-option');
if (!btn) return;
const originalId = btn.dataset.originalId;
const altId = btn.dataset.altId;
if (altId === originalId) {
delete currentSubstitutions[originalId];
} else {
currentSubstitutions[originalId] = altId;
}
expandedVariantGroups.delete(originalId);
const recipe = RECIPES[currentRecipeId];
if (recipe) renderPlannerVariants(recipe);
});
function openPlannerPicker() { function openPlannerPicker() {
const recipe = RECIPES[currentRecipeId]; const recipe = RECIPES[currentRecipeId];
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();
const daysContainer = document.getElementById('rd-planner-days');
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const days = [];
for (let i = 0; i < 7; i++) days.push(addDays(today, i));
plannerPickerDay = today; plannerPickerDay = today;
calViewDate = today;
calExpanded = false;
renderPlannerCalendar();
renderPlannerVariants(recipe);
plannerPickerSlot = recipe.allowedSlots[0] || MEAL_SLOTS[0]?.id; plannerPickerSlot = recipe.allowedSlots[0] || MEAL_SLOTS[0]?.id;
daysContainer.innerHTML = days.map((d, idx) => {
const wd = WEEKDAYS_LONG[d.getDay()];
const label = idx === 0 ? `Dziś — ${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}` : `${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
const sel = idx === 0;
return `<button type="button" class="rd-plan-day-btn w-full text-left px-3 py-2.5 rounded-xl border text-[13px] font-semibold transition-all ${sel ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-gray-50 text-gray-900 hover:border-gray-400'}" data-day-ts="${d.getTime()}">${escapeHtml(label)}</button>`;
}).join('');
const slotsContainer = document.getElementById('rd-planner-slots'); const slotsContainer = document.getElementById('rd-planner-slots');
slotsContainer.innerHTML = MEAL_SLOTS.filter((s) => recipe.allowedSlots.includes(s.id)).map((s) => { slotsContainer.innerHTML = MEAL_SLOTS.filter((s) => recipe.allowedSlots.includes(s.id)).map((s) => {
const sel = s.id === plannerPickerSlot; const sel = s.id === plannerPickerSlot;
@@ -360,18 +611,6 @@ export function setupRecipeDetail() {
if (e.target === plannerOverlay) closePlannerPicker(); if (e.target === plannerOverlay) closePlannerPicker();
}); });
document.getElementById('rd-planner-days')?.addEventListener('click', (e) => {
const btn = e.target.closest('.rd-plan-day-btn');
if (!btn) return;
plannerPickerDay = new Date(Number(btn.getAttribute('data-day-ts')));
document.querySelectorAll('.rd-plan-day-btn').forEach((b) => {
b.classList.remove('border-gray-900', 'bg-gray-900', 'text-white');
b.classList.add('border-gray-200', 'bg-gray-50', 'text-gray-900');
});
btn.classList.remove('border-gray-200', 'bg-gray-50', 'text-gray-900');
btn.classList.add('border-gray-900', 'bg-gray-900', 'text-white');
});
document.getElementById('rd-planner-slots')?.addEventListener('click', (e) => { document.getElementById('rd-planner-slots')?.addEventListener('click', (e) => {
const btn = e.target.closest('.rd-plan-slot-btn'); const btn = e.target.closest('.rd-plan-slot-btn');
if (!btn) return; if (!btn) return;
@@ -390,11 +629,17 @@ export function setupRecipeDetail() {
const key = dateKey(plannerPickerDay); const key = dateKey(plannerPickerDay);
if (!plans[key]) plans[key] = {}; if (!plans[key]) plans[key] = {};
if (!plans[key][plannerPickerSlot]) plans[key][plannerPickerSlot] = []; if (!plans[key][plannerPickerSlot]) plans[key][plannerPickerSlot] = [];
plans[key][plannerPickerSlot].push({
const entry = {
id: newPlanEntryId(), id: newPlanEntryId(),
recipeId: currentRecipeId, recipeId: currentRecipeId,
servings: currentServings, servings: currentServings,
}); };
if (Object.keys(currentSubstitutions).length > 0) {
entry.substitutions = { ...currentSubstitutions };
}
plans[key][plannerPickerSlot].push(entry);
savePlans(plans); savePlans(plans);
closePlannerPicker(); closePlannerPicker();
showAppToast('Dodano do planera!'); showAppToast('Dodano do planera!');