Add meal planner

This commit is contained in:
2026-03-20 21:11:54 +01:00
parent 9614fefe05
commit 7b9db1c5e6
4 changed files with 369 additions and 10 deletions

View File

@@ -1,18 +1,77 @@
import { getRecipeListHTML } from './views/RecipeList.js';
import { getFilterHTML, setupFilter } from './views/Filter.js';
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js';
function getBottomNavHTML() {
return `
<nav id="app-bottom-nav" class="absolute bottom-0 left-0 right-0 w-full bg-white border-t border-gray-200 flex justify-between px-4 py-3 pb-6 z-20" aria-label="Główna nawigacja">
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]">
<i class="fas fa-book text-xl" aria-hidden="true"></i>
<span class="text-[11px] font-medium">Przepisy</span>
</button>
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]">
<i class="far fa-calendar-alt text-xl" aria-hidden="true"></i>
<span class="text-[11px] font-medium">Planer</span>
</button>
<button type="button" data-tab="shopping" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]" disabled title="Wkrótce">
<i class="fas fa-shopping-cart text-xl" aria-hidden="true"></i>
<span class="text-[11px] font-medium">Zakupy</span>
</button>
<button type="button" data-tab="pantry" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]" disabled title="Wkrótce">
<i class="fas fa-box text-xl" aria-hidden="true"></i>
<span class="text-[11px] font-medium">Zapasy</span>
</button>
</nav>
`;
}
function setupTabs() {
const main = document.getElementById('main-view');
const planner = document.getElementById('planner-view');
const nav = document.getElementById('app-bottom-nav');
if (!main || !planner || !nav) return;
const activeTab = 'nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]';
const idleTab = 'nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]';
const apply = (tab) => {
const showRecipes = tab === 'recipes';
main.classList.toggle('hidden', !showRecipes);
planner.classList.toggle('hidden', showRecipes);
nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => {
const id = btn.getAttribute('data-tab');
if (btn.hasAttribute('disabled')) return;
if (id === 'recipes' || id === 'planner') {
btn.className = id === tab ? activeTab : idleTab;
}
});
};
nav.addEventListener('click', (e) => {
const btn = e.target.closest('.nav-tab[data-tab]');
if (!btn || btn.hasAttribute('disabled')) return;
const tab = btn.getAttribute('data-tab');
if (tab === 'recipes' || tab === 'planner') apply(tab);
});
apply('recipes');
}
document.addEventListener('DOMContentLoaded', () => {
const appContainer = document.getElementById('app-container');
// Inject all views into the main container
appContainer.innerHTML = `
${getRecipeListHTML()}
${getMealPlannerHTML()}
${getBottomNavHTML()}
${getRecipeDetailHTML()}
${getFilterHTML()}
`;
// Initialize logic for the injected views
setupTabs();
setupMealPlanner();
setupFilter();
setupRecipeDetail();
});

307
js/views/MealPlanner.js Normal file
View File

@@ -0,0 +1,307 @@
const MONTHS_SHORT = [
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
];
const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
function addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return startOfDay(x);
}
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
function startOfWeekMonday(d) {
const date = startOfDay(d);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
return addDays(date, diff);
}
function startOfMonth(d) {
const x = new Date(d.getFullYear(), d.getMonth(), 1);
return startOfDay(x);
}
function addMonths(d, n) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return startOfDay(x);
}
function addWeeks(d, n) {
return addDays(d, n * 7);
}
function weekContains(weekStart, d) {
const t = startOfDay(d).getTime();
const ws = weekStart.getTime();
const we = addDays(weekStart, 6).getTime();
return t >= ws && t <= we;
}
function sameMonth(a, b) {
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
}
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
const today = startOfDay(new Date());
if (!sameDay(selected, today)) return false;
if (mode === 'week') return weekContains(weekStart, today);
return sameMonth(monthAnchor, today);
}
function syncTodayButton(mode, weekStart, monthAnchor, selected) {
const btn = document.getElementById('cal-go-today');
if (!btn) return;
const onToday = isCalendarOnToday(mode, weekStart, monthAnchor, selected);
const active = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors';
const dim = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-100 bg-gray-50 px-2 text-[10px] font-semibold text-gray-400 shadow-none cursor-default transition-colors';
btn.className = onToday ? dim : active;
btn.disabled = onToday;
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 bg-gray-50 z-10 pb-24">
<div class="shrink-0 bg-white border-b border-gray-200 mt-3">
<div class="px-3 pt-2 pb-1.5 flex items-center gap-1">
<button type="button" id="cal-prev" 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" aria-label="Poprzedni okres">
<i class="fas fa-chevron-left text-xs" aria-hidden="true"></i>
</button>
<p id="cal-period-label" class="flex-1 min-w-0 text-xs font-medium text-gray-900 text-center tabular-nums leading-none px-1 truncate"></p>
<button type="button" id="cal-next" 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" aria-label="Następny okres">
<i class="fas fa-chevron-right text-xs" aria-hidden="true"></i>
</button>
</div>
<div class="px-3 pb-2 flex items-center justify-between gap-3">
<button type="button" id="cal-go-today" title="Dziś" aria-label="Przejdź do dzisiejszego dnia"
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">
<i class="fas fa-calendar-day text-[9px] opacity-70" aria-hidden="true"></i>
Dziś
</button>
<div id="planner-cal-mode" class="inline-flex items-center shrink-0 rounded-md border border-gray-200 bg-gray-50 p-px gap-px shadow-sm" role="tablist" aria-label="Skala kalendarza">
<button type="button" data-cal-mode="week" id="planner-mode-week" title="Tydzień" aria-label="Widok tygodnia"
class="planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors bg-white text-gray-900 shadow-sm">
<i class="fas fa-calendar-week text-[10px]" aria-hidden="true"></i>
</button>
<button type="button" data-cal-mode="month" id="planner-mode-month" title="Miesiąc" aria-label="Widok miesiąca"
class="planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors text-gray-400 hover:text-gray-600">
<i class="fas fa-calendar-days text-[10px]" aria-hidden="true"></i>
</button>
</div>
</div>
<div id="calendar-week-wrap" class="px-3 pb-2.5">
<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="calendar-week-grid" class="grid grid-cols-7 gap-0.5"></div>
</div>
<div id="calendar-month-wrap" class="hidden px-3 pb-2.5">
<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="calendar-month-grid" class="grid grid-cols-7 gap-0.5"></div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pt-3">
<p class="text-sm text-gray-500 leading-relaxed">Dotknij dnia w kalendarzu, aby później przypisać posiłki. Ta sekcja na razie jest zapowiedzią rozbudowy planera.</p>
</div>
</div>
`;
}
function renderWeekGrid(weekStart, selected) {
const grid = document.getElementById('calendar-week-grid');
if (!grid) return;
const cells = [];
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i);
const isSel = selected && sameDay(day, selected);
const isToday = sameDay(day, new Date());
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0
${isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100'}
${isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
<span>${day.getDate()}</span>
</button>
`);
}
grid.innerHTML = cells.join('');
}
function renderMonthGrid(monthAnchor, selected) {
const grid = document.getElementById('calendar-month-grid');
if (!grid) return;
const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first);
const cells = [];
for (let i = 0; i < 42; i++) {
const day = addDays(startGrid, i);
const inMonth = day.getMonth() === first.getMonth();
const isSel = selected && sameDay(day, selected);
const isToday = sameDay(day, new Date());
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0
${!inMonth ? 'text-gray-300' : (isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100')}
${inMonth && isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
<span>${day.getDate()}</span>
</button>
`);
}
grid.innerHTML = cells.join('');
}
function updatePeriodLabel(mode, weekStart, monthAnchor) {
const el = document.getElementById('cal-period-label');
if (!el) return;
if (mode === 'week') {
const end = addDays(weekStart, 6);
const y = weekStart.getFullYear();
if (weekStart.getMonth() === end.getMonth()) {
el.textContent = `${weekStart.getDate()}${end.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${y}`;
} else {
el.textContent = `${weekStart.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${end.getDate()} ${MONTHS_SHORT[end.getMonth()]} ${y}`;
}
} else {
const m = monthAnchor.getMonth();
const y = monthAnchor.getFullYear();
const monthLong = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
][m];
el.textContent = `${monthLong} ${y}`;
}
}
function syncModeToggle(mode) {
const w = document.getElementById('planner-mode-week');
const m = document.getElementById('planner-mode-month');
const weekWrap = document.getElementById('calendar-week-wrap');
const monthWrap = document.getElementById('calendar-month-wrap');
const base = 'planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors';
const active = `${base} bg-white text-gray-900 shadow-sm`;
const idle = `${base} text-gray-400 hover:text-gray-600`;
if (w && m) {
if (mode === 'week') {
w.className = active;
m.className = idle;
} else {
w.className = idle;
m.className = active;
}
}
if (weekWrap && monthWrap) {
weekWrap.classList.toggle('hidden', mode !== 'week');
monthWrap.classList.toggle('hidden', mode !== 'month');
}
}
function bindDayClicks(container, state, rerender) {
container?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-planner-day]');
if (!btn) return;
const ts = Number(btn.getAttribute('data-planner-day'));
state.selected = new Date(ts);
rerender();
});
}
export function setupMealPlanner() {
const state = {
mode: 'week',
weekStart: startOfWeekMonday(new Date()),
monthAnchor: startOfDay(new Date()),
selected: startOfDay(new Date()),
};
const weekGrid = document.getElementById('calendar-week-grid');
const monthGrid = document.getElementById('calendar-month-grid');
const rerender = () => {
syncModeToggle(state.mode);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
if (state.mode === 'week') {
renderWeekGrid(state.weekStart, state.selected);
} else {
renderMonthGrid(state.monthAnchor, state.selected);
}
};
bindDayClicks(weekGrid?.parentElement, state, rerender);
bindDayClicks(monthGrid?.parentElement, state, rerender);
document.getElementById('planner-cal-mode')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-cal-mode]');
if (!btn) return;
const mode = btn.getAttribute('data-cal-mode');
if (mode !== 'week' && mode !== 'month') return;
state.mode = mode;
if (mode === 'week') {
state.weekStart = startOfWeekMonday(state.selected);
} else {
state.monthAnchor = startOfMonth(state.selected);
}
rerender();
});
document.getElementById('cal-prev')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, -1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, -1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-next')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
state.selected = today;
state.weekStart = startOfWeekMonday(today);
state.monthAnchor = startOfMonth(today);
rerender();
});
rerender();
}

View File

@@ -169,13 +169,6 @@ export function getRecipeListHTML() {
</div>
</div>
<div class="absolute bottom-0 w-full bg-white border-t border-gray-200 flex justify-between px-6 py-3 pb-6 z-20">
<button class="flex flex-col items-center gap-1 text-black"><i class="fas fa-book text-xl"></i><span class="text-[11px] font-medium">Przepisy</span></button>
<button class="flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700"><i class="far fa-calendar-alt text-xl"></i><span class="text-[11px] font-medium">Planer</span></button>
<button class="flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700"><i class="fas fa-shopping-cart text-xl"></i><span class="text-[11px] font-medium">Zakupy</span></button>
<button class="flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700"><i class="fas fa-box text-xl"></i><span class="text-[11px] font-medium">Zapasy</span></button>
</div>
</div>
`;
}