diff --git a/js/app.js b/js/app.js
index b99edb2..20a5122 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1,6 +1,6 @@
import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js';
import { getFilterHTML, setupFilter } from './views/Filter.js';
-import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
+import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetailV2.js';
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js';
import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js';
import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js';
diff --git a/js/views/RecipeDetailV2.js b/js/views/RecipeDetailV2.js
new file mode 100644
index 0000000..8edca87
--- /dev/null
+++ b/js/views/RecipeDetailV2.js
@@ -0,0 +1,716 @@
+import { RECIPES, INGREDIENTS } from '../data/catalog.js';
+import { MEAL_SLOTS } from '../planner/mealSlots.js';
+import { addDays, addMonths, sameDay, sameMonth, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
+import { dateKey, loadPlans, newPlanEntryId, savePlans } from '../services/planStore.js';
+import { showAppToast } from '../ui/toast.js';
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+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() {
+ return `
+
+
+
+
+
+
+
+
+
+
Zaplanuj
+
+
+
+
+
+
+
+
+ ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
+
+
+
+
+
+
Pora posiłku
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+/* ── state ─────────────────────────────────────────────── */
+
+let currentRecipeId = null;
+let currentServings = 1;
+let currentSubstitutions = {};
+let expandedAlternatives = new Set();
+
+function getEffectiveIngredientId(originalId) {
+ return currentSubstitutions[originalId] || originalId;
+}
+
+/* ── populate ──────────────────────────────────────────── */
+
+function populateDetail(recipeId) {
+ const recipe = RECIPES[recipeId];
+ if (!recipe) return;
+
+ currentRecipeId = recipeId;
+ currentServings = 1;
+ currentSubstitutions = {};
+ expandedAlternatives.clear();
+
+ document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`;
+ document.getElementById('rd-title').textContent = recipe.title;
+ document.getElementById('rd-time').textContent = `${recipe.minutes} min`;
+ updateKcalDisplay();
+
+ const tagsHtml = [];
+ for (const slotId of recipe.allowedSlots) {
+ const label = slotLabelMap[slotId];
+ if (label) tagsHtml.push(`${escapeHtml(label)}`);
+ }
+ for (const tag of (recipe.tags || [])) {
+ tagsHtml.push(`${escapeHtml(tag)}`);
+ }
+ document.getElementById('rd-tags').innerHTML = tagsHtml.join('');
+ document.getElementById('rd-servings').textContent = '1';
+
+ renderIngredients(recipe);
+ renderSteps(recipe);
+
+ const tabBtns = document.querySelectorAll('.rd-tab-btn');
+ const tabs = document.querySelectorAll('.rd-tab-content');
+ tabBtns.forEach((b) => {
+ b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
+ b.classList.add('text-gray-500', 'border-transparent', 'font-medium');
+ });
+ tabBtns[0]?.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
+ tabBtns[0]?.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
+ tabs.forEach((t) => { t.classList.add('hidden'); t.classList.remove('block'); });
+ document.getElementById('rd-tab-ingredients')?.classList.remove('hidden');
+ document.getElementById('rd-tab-ingredients')?.classList.add('block');
+}
+
+/* ── helpers ───────────────────────────────────────────── */
+
+function updateKcalDisplay() {
+ const el = document.getElementById('rd-kcal');
+ if (!el) return;
+ const recipe = RECIPES[currentRecipeId];
+ if (!recipe) return;
+ const kcal = Math.round(recipe.nutritionPerServing.kcal * currentServings);
+ el.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 `${nutrition.kcal} kcal${nutrition.protein}g białko${nutrition.fat}g tłuszcz${nutrition.carbs}g węgle
`;
+}
+
+/* ── ingredients tab with inline nutrition + summary ───── */
+
+function computeIngredientNutritionTotals(recipe) {
+ let kcal = 0, protein = 0, fat = 0, carbs = 0;
+ for (const ing of recipe.ingredients) {
+ const n = nutritionForAmount(ing.ingredientId, ing.amount * currentServings);
+ if (n) {
+ kcal += n.kcal;
+ protein += n.protein;
+ fat += n.fat;
+ carbs += n.carbs;
+ }
+ }
+ return {
+ kcal: Math.round(kcal),
+ protein: Math.round(protein * 10) / 10,
+ fat: Math.round(fat * 10) / 10,
+ carbs: Math.round(carbs * 10) / 10,
+ };
+}
+
+function renderNutritionSummary(recipe) {
+ const n = recipe.nutritionPerServing;
+ const s = currentServings;
+ const total = {
+ kcal: Math.round(n.kcal * s * 10) / 10,
+ protein: Math.round(n.protein * s * 10) / 10,
+ fat: Math.round(n.fat * s * 10) / 10,
+ carbs: Math.round(n.carbs * s * 10) / 10,
+ };
+
+ return `
+
+ ${s > 1 ? `
Wartości dla ${s} porcji
` : ''}
+
+
+ ${total.kcal}
+ Kalorie
+
+
+ ${total.protein} g
+ Białko
+
+
+ ${total.carbs} g
+ Węglo.
+
+
+ ${total.fat} g
+ Tłuszcze
+
+
+
`;
+}
+
+function renderIngredientCard(name, amount, unit, nutrition, extra) {
+ const displayAmount = Number.isInteger(amount) ? amount : parseFloat(amount.toFixed(1));
+ const macroHtml = nutrition
+ ? `
+ ${nutrition.protein}g B${nutrition.fat}g T${nutrition.carbs}g W
+
`
+ : '';
+ const kcalHtml = nutrition
+ ? `${nutrition.kcal} kcal`
+ : '';
+
+ return `
+ `;
+}
+
+function renderIngredients(recipe) {
+ const container = document.getElementById('rd-tab-ingredients');
+ if (!container) return;
+
+ const rows = recipe.ingredients.map((ing) => {
+ const def = INGREDIENTS[ing.ingredientId];
+ const name = def?.name || ing.ingredientId;
+ const scaledAmount = ing.amount * currentServings;
+ const nutrition = nutritionForAmount(ing.ingredientId, scaledAmount);
+
+ const hasAlts = ing.alternatives && ing.alternatives.length > 0;
+ const isExpanded = expandedAlternatives.has(ing.ingredientId);
+
+ const toggleBtn = hasAlts
+ ? ``
+ : '';
+
+ const cardHtml = renderIngredientCard(name, scaledAmount, ing.unit, nutrition, { suffix: toggleBtn });
+
+ let altListHtml = '';
+ if (hasAlts) {
+ const altCards = ing.alternatives.map((altId) => {
+ const altDef = INGREDIENTS[altId];
+ const altName = altDef?.name || altId;
+ const altNutrition = nutritionForAmount(altId, scaledAmount);
+ return renderIngredientCard(altName, scaledAmount, ing.unit, altNutrition, {
+ cls: 'bg-gray-50',
+ border: 'border-gray-100',
+ });
+ });
+ altListHtml = `
+
+
Można zamienić na:
+ ${altCards.join('')}
+
`;
+ }
+
+ return `${cardHtml}${altListHtml}`;
+ }).join('');
+
+ container.innerHTML = `
+ ${renderNutritionSummary(recipe)}
+ `;
+
+ container.querySelectorAll('.rd-alt-toggle').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const origId = btn.dataset.originalId;
+ if (expandedAlternatives.has(origId)) {
+ expandedAlternatives.delete(origId);
+ } else {
+ expandedAlternatives.add(origId);
+ }
+ const list = container.querySelector(`.rd-alt-list[data-original-id="${origId}"]`);
+ if (list) list.classList.toggle('hidden');
+ btn.classList.toggle('bg-gray-100');
+ btn.classList.toggle('text-gray-400');
+ btn.classList.toggle('bg-amber-50');
+ btn.classList.toggle('text-amber-500');
+ });
+ });
+}
+
+/* ── steps tab ─────────────────────────────────────────── */
+
+function renderSteps(recipe) {
+ const container = document.getElementById('rd-tab-steps');
+ if (!container) return;
+
+ const steps = recipe.steps || [];
+ if (steps.length === 0) {
+ container.innerHTML = 'Brak kroków przygotowania.
';
+ return;
+ }
+
+ container.innerHTML = `
+
+ ${steps.map((step, i) => `
+
`).join('')}
+
`;
+}
+
+/* ── setup ─────────────────────────────────────────────── */
+
+export function setupRecipeDetail() {
+ document.querySelectorAll('.rd-tab-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const tabId = btn.dataset.rdTab;
+ document.querySelectorAll('.rd-tab-content').forEach((el) => {
+ el.classList.add('hidden');
+ el.classList.remove('block');
+ });
+ const target = document.getElementById(`rd-tab-${tabId}`);
+ if (target) {
+ target.classList.remove('hidden');
+ target.classList.add('block');
+ target.parentElement.scrollTop = 0;
+ }
+
+ document.querySelectorAll('.rd-tab-btn').forEach((b) => {
+ b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
+ b.classList.add('text-gray-500', 'border-transparent', 'font-medium');
+ });
+ btn.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
+ btn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
+ });
+ });
+
+ document.getElementById('rd-serv-minus')?.addEventListener('click', () => {
+ if (currentServings <= 1) return;
+ currentServings--;
+ document.getElementById('rd-servings').textContent = currentServings;
+ const recipe = RECIPES[currentRecipeId];
+ if (recipe) {
+ renderIngredients(recipe);
+ updateKcalDisplay();
+ }
+ });
+
+ document.getElementById('rd-serv-plus')?.addEventListener('click', () => {
+ if (currentServings >= 12) return;
+ currentServings++;
+ document.getElementById('rd-servings').textContent = currentServings;
+ const recipe = RECIPES[currentRecipeId];
+ if (recipe) {
+ renderIngredients(recipe);
+ updateKcalDisplay();
+ }
+ });
+
+ /* ── planner picker ──────────────────────────────── */
+
+ let plannerPickerDay = null;
+ let plannerPickerSlot = null;
+ let calViewDate = null;
+ let calExpanded = false;
+
+ const plannerOverlay = document.getElementById('rd-planner-picker');
+ 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 = `${d.getDate()}`;
+
+ if (isPast && !isSelected) {
+ return `${inner}
`;
+ }
+
+ return ``;
+ }
+
+ 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 += `
+
`;
+
+ if (isExpanded) {
+ const allOptions = [origId, ...ing.alternatives];
+ html += '
';
+ 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 = `
Domyślny`;
+
+ html += `
+
`;
+ }
+ html += '
';
+ }
+
+ html += '
';
+ }
+
+ 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() {
+ const recipe = RECIPES[currentRecipeId];
+ if (!recipe) return;
+
+ document.getElementById('rd-planner-recipe-name').textContent = recipe.title;
+ currentSubstitutions = {};
+ expandedVariantGroups.clear();
+
+ const today = startOfDay(new Date());
+ plannerPickerDay = today;
+ calViewDate = today;
+ calExpanded = false;
+ renderPlannerCalendar();
+
+ renderPlannerVariants(recipe);
+
+ plannerPickerSlot = recipe.allowedSlots[0] || MEAL_SLOTS[0]?.id;
+ const slotsContainer = document.getElementById('rd-planner-slots');
+ slotsContainer.innerHTML = MEAL_SLOTS.filter((s) => recipe.allowedSlots.includes(s.id)).map((s) => {
+ const sel = s.id === plannerPickerSlot;
+ return ``;
+ }).join('');
+
+ plannerOverlay.classList.remove('hidden');
+ plannerOverlay.style.pointerEvents = 'auto';
+ requestAnimationFrame(() => {
+ plannerSheet.style.transform = 'translateY(0)';
+ });
+ }
+
+ function closePlannerPicker() {
+ plannerSheet.style.transform = 'translateY(100%)';
+ setTimeout(() => {
+ plannerOverlay.classList.add('hidden');
+ plannerOverlay.style.pointerEvents = 'none';
+ }, 300);
+ }
+
+ document.getElementById('rd-add-to-planner-btn')?.addEventListener('click', openPlannerPicker);
+
+ plannerOverlay?.addEventListener('click', (e) => {
+ if (e.target === plannerOverlay) closePlannerPicker();
+ });
+
+ document.getElementById('rd-planner-slots')?.addEventListener('click', (e) => {
+ const btn = e.target.closest('.rd-plan-slot-btn');
+ if (!btn) return;
+ plannerPickerSlot = btn.getAttribute('data-slot-id');
+ document.querySelectorAll('.rd-plan-slot-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-700');
+ });
+ btn.classList.remove('border-gray-200', 'bg-gray-50', 'text-gray-700');
+ btn.classList.add('border-gray-900', 'bg-gray-900', 'text-white');
+ });
+
+ document.getElementById('rd-planner-confirm')?.addEventListener('click', () => {
+ if (!currentRecipeId || !plannerPickerDay || !plannerPickerSlot) return;
+ const plans = loadPlans();
+ const key = dateKey(plannerPickerDay);
+ if (!plans[key]) plans[key] = {};
+ if (!plans[key][plannerPickerSlot]) plans[key][plannerPickerSlot] = [];
+
+ const entry = {
+ id: newPlanEntryId(),
+ recipeId: currentRecipeId,
+ servings: currentServings,
+ };
+ if (Object.keys(currentSubstitutions).length > 0) {
+ entry.substitutions = { ...currentSubstitutions };
+ }
+
+ plans[key][plannerPickerSlot].push(entry);
+ savePlans(plans);
+ closePlannerPicker();
+ showAppToast('Dodano do planera!');
+ window.refreshPlanner?.();
+ });
+
+ window.openRecipeDetail = (recipeId) => {
+ if (!recipeId || !RECIPES[recipeId]) return;
+ populateDetail(recipeId);
+ const view = document.getElementById('recipe-detail-view');
+ view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
+ view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto');
+ };
+
+ window.closeRecipeDetail = () => {
+ closePlannerPicker();
+ const view = document.getElementById('recipe-detail-view');
+ view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto');
+ view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
+ };
+}