Rework calendar

This commit is contained in:
2026-04-04 16:54:42 +02:00
parent c11f184d1c
commit 20424b4ecb
10 changed files with 821 additions and 341 deletions

View File

@@ -4,7 +4,6 @@ import {
addDays,
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
@@ -26,12 +25,20 @@ import {
newPlanEntryId,
savePlans,
} from '../services/planStore.js';
import {
CALENDAR_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT,
bindCalendarDayClicks,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
formatCalendarMonthYear,
formatCalendarSelectedDate,
isCalendarOnToday,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=1';
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'];
const WEEKDAYS_LONG = [
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
];
@@ -44,58 +51,34 @@ function recipesForSlot(slotId) {
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
}
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;
syncCalendarTodayButton(
document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected),
);
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden 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-center">
<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>
<div id="calendar-swipe-zone" style="touch-action: pan-x">
<div id="calendar-week-wrap" class="px-3 pb-1" style="overflow: hidden; max-height: 10rem; opacity: 1">
<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>
${createCalendarTopbarHTML({
titleId: 'cal-period-label',
prevId: 'cal-prev',
todayId: 'cal-go-today',
nextId: 'cal-next',
})}
<div id="calendar-swipe-zone" class="overflow-x-hidden" style="touch-action: none">
<div id="calendar-week-wrap" class="px-3 overflow-x-hidden" style="overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-week-grid" class="grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden"></div>
</div>
<div id="calendar-month-wrap" class="px-3 pb-1" style="overflow: hidden; max-height: 0; opacity: 0">
<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 id="calendar-month-wrap" class="px-3" style="overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-month-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<div id="calendar-drag-handle" class="flex items-center justify-center pb-2 pt-0.5">
<i id="calendar-handle-icon" class="fas fa-chevron-down text-[8px] text-gray-300" aria-hidden="true"></i>
<span id="calendar-handle-icon" class="${CALENDAR_HANDLE_CLASS}" aria-hidden="true"></span>
</div>
</div>
</div>
@@ -199,105 +182,23 @@ export function getMealPlannerHTML() {
`;
}
function renderWeekGrid(weekStart, selected, plans) {
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());
const hasMeals = dayHasAnyMeal(plans, day);
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="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
${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>
${hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
</button>
`);
}
grid.innerHTML = cells.join('');
}
function renderMonthGrid(monthAnchor, selected, plans) {
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());
const hasMeals = inMonth && dayHasAnyMeal(plans, day);
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="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
${!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>
${inMonth && hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
</button>
`);
}
grid.innerHTML = cells.join('');
}
function updatePeriodLabel(mode, weekStart, monthAnchor) {
function updatePeriodLabel(mode, weekStart, monthAnchor, selected) {
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}`;
}
el.textContent = formatCalendarSelectedDate(selected);
} 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}`;
el.textContent = formatCalendarMonthYear(monthAnchor);
}
}
function syncModeToggle(mode) {
const weekWrap = document.getElementById('calendar-week-wrap');
const monthWrap = document.getElementById('calendar-month-wrap');
const handleIcon = document.getElementById('calendar-handle-icon');
if (weekWrap) {
weekWrap.style.maxHeight = mode === 'week' ? '10rem' : '0';
weekWrap.style.opacity = mode === 'week' ? '1' : '0';
}
if (monthWrap) {
monthWrap.style.maxHeight = mode === 'month' ? '25rem' : '0';
monthWrap.style.opacity = mode === 'month' ? '1' : '0';
}
if (handleIcon) {
handleIcon.className = mode === 'week'
? 'fas fa-chevron-down text-[8px] text-gray-300'
: 'fas fa-chevron-up text-[8px] text-gray-300';
}
}
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();
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
handleEl: document.getElementById('calendar-handle-icon'),
});
}
@@ -448,7 +349,7 @@ function renderDayContent(state) {
const heading = document.getElementById('planner-day-heading');
if (heading) {
const wd = WEEKDAYS_LONG[sel.getDay()];
heading.textContent = `${wd}, ${sel.getDate()} ${MONTHS_SHORT[sel.getMonth()]}`;
heading.textContent = `${wd}, ${sel.getDate()} ${CALENDAR_MONTHS_SHORT[sel.getMonth()]}`;
}
const dayPlan = getDayPlan(state.plans, sel);
@@ -753,7 +654,7 @@ function renderIngredientsSheet(state) {
if (titleEl) {
const wd = WEEKDAYS_LONG[state.selected.getDay()];
titleEl.textContent = `${wd}, ${state.selected.getDate()} ${MONTHS_SHORT[state.selected.getMonth()]} — składniki`;
titleEl.textContent = `${wd}, ${state.selected.getDate()} ${CALENDAR_MONTHS_SHORT[state.selected.getMonth()]} — składniki`;
}
if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.';
@@ -845,7 +746,7 @@ function renderIngredientsSheet(state) {
<div class="space-y-2">
${upcoming.map((day) => {
const wd = WEEKDAYS_LONG[day.date.getDay()];
const label = `${wd}, ${day.date.getDate()} ${MONTHS_SHORT[day.date.getMonth()]}`;
const label = `${wd}, ${day.date.getDate()} ${CALENDAR_MONTHS_SHORT[day.date.getMonth()]}`;
const shorts = day.items.filter((it) => !it.enough);
return `<div class="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
<p class="text-[12px] font-semibold text-amber-900">
@@ -912,10 +813,21 @@ export function setupMealPlanner() {
const rerender = () => {
syncModeToggle(state.mode);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor, state.selected);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
renderWeekGrid(state.weekStart, state.selected, state.plans);
renderMonthGrid(state.monthAnchor, state.selected, state.plans);
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => ({
dimmed: meta.mode === 'month' && !meta.inCurrentMonth,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
}),
});
renderDayContent(state);
};
@@ -924,8 +836,14 @@ export function setupMealPlanner() {
rerender();
};
bindDayClicks(weekGrid?.parentElement, state, rerender);
bindDayClicks(monthGrid?.parentElement, state, rerender);
bindCalendarDayClicks(weekGrid, (date) => {
state.selected = date;
rerender();
});
bindCalendarDayClicks(monthGrid, (date) => {
state.selected = date;
rerender();
});
document.getElementById('cal-prev')?.addEventListener('click', () => {
if (state.mode === 'week') {
@@ -1117,7 +1035,7 @@ export function setupMealPlanner() {
}
copyList.innerHTML = days.map((d) => {
const wd = WEEKDAYS_LONG[d.getDay()];
const label = `${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
const label = `${wd}, ${d.getDate()} ${CALENDAR_MONTHS_SHORT[d.getMonth()]}`;
const hasMeals = dayHasAnyMeal(state.plans, d);
const badge = hasMeals ? '<span class="text-[10px] text-amber-600 font-semibold">ma posiłki</span>' : '';
return `<button type="button" class="planner-copy-target w-full flex items-center justify-between gap-2 p-3 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white transition-all text-left" data-target-ts="${d.getTime()}">