Reorganise adding recipe to plan
Some checks failed
Build and Deploy / build-and-push (push) Failing after 21s
Some checks failed
Build and Deploy / build-and-push (push) Failing after 21s
This commit is contained in:
@@ -12,108 +12,154 @@
|
|||||||
|-------|------|
|
|-------|------|
|
||||||
| Liczba osób | 1 (gotuje dla siebie) |
|
| Liczba osób | 1 (gotuje dla siebie) |
|
||||||
| Planowanie | Elastyczne, 1–7 dni do przodu |
|
| Planowanie | Elastyczne, 1–7 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 (5–120 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 1–12) 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 |
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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');
|
|
||||||
if (checkedRows.length === 0) {
|
|
||||||
showAppToast('Zaznacz składniki, które chcesz dodać.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const list = container.querySelector(`.rd-alt-list[data-original-id="${origId}"]`);
|
||||||
const lines = [];
|
if (list) list.classList.toggle('hidden');
|
||||||
checkedRows.forEach((row) => {
|
btn.classList.toggle('bg-gray-100');
|
||||||
const ingredientId = row.dataset.ingredientId;
|
btn.classList.toggle('text-gray-400');
|
||||||
const baseAmount = parseFloat(row.dataset.baseAmount);
|
btn.classList.toggle('bg-amber-50');
|
||||||
const unit = row.dataset.unit;
|
btn.classList.toggle('text-amber-500');
|
||||||
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!');
|
||||||
|
|||||||
Reference in New Issue
Block a user