import { addDays, isoToDate, todayISO } from './stats.js'; const DOW = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; function startOfMonth(year, month) { return new Date(year, month, 1); } function endOfMonth(year, month) { return new Date(year, month + 1, 0); } function isoOf(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** * Render a month grid into `root`. * * @param {HTMLElement} root * @param {object} opts * @param {number} opts.year * @param {number} opts.month 0-indexed (Jan = 0) * @param {Array} opts.goals all goals * @param {Array} opts.completions flat completions array * @param {string} opts.activeUser 'ray' | 'cer' * @param {(args: {iso: string}) => void} opts.onDayClick * @param {() => void} opts.onPrev * @param {() => void} opts.onNext */ export function renderCalendar(root, opts) { const { year, month, goals, completions, activeUser, onDayClick, onPrev, onNext } = opts; const today = todayISO(); const goalRange = goals.reduce( (acc, g) => ({ min: g.start_date < acc.min ? g.start_date : acc.min, max: g.end_date > acc.max ? g.end_date : acc.max, }), { min: '9999-12-31', max: '0000-01-01' } ); // Index completions by date for O(1) lookup const byDate = new Map(); for (const c of completions) { if (!c.completed) continue; if (!byDate.has(c.date)) byDate.set(c.date, []); byDate.get(c.date).push(c); } const first = startOfMonth(year, month); const last = endOfMonth(year, month); // Use Mon as first column. JS getDay(): 0=Sun..6=Sat → mon-index = (d+6)%7 const leadBlanks = (first.getDay() + 6) % 7; const totalCells = leadBlanks + last.getDate(); const trailBlanks = (7 - (totalCells % 7)) % 7; const monthName = first.toLocaleString(undefined, { month: 'long' }); // sort goals deterministically (sugar before social) const orderedGoals = [...goals].sort((a, b) => { if (a.start_date !== b.start_date) return a.start_date.localeCompare(b.start_date); return a.id.localeCompare(b.id); }); root.innerHTML = ''; const head = document.createElement('div'); head.className = 'cal-head'; head.innerHTML = `
${monthName} ${year}
`; head.querySelector('[data-prev]').addEventListener('click', onPrev); head.querySelector('[data-next]').addEventListener('click', onNext); head.querySelector('[data-today]').addEventListener('click', () => { const t = new Date(); if (opts.onJumpToday) opts.onJumpToday(t.getFullYear(), t.getMonth()); }); root.appendChild(head); const grid = document.createElement('div'); grid.className = 'cal-grid'; // DOW header row const dow = document.createElement('div'); dow.className = 'cal-dow'; for (const name of DOW) { const c = document.createElement('div'); c.className = 'cal-dow__cell'; c.textContent = name; dow.appendChild(c); } grid.appendChild(dow); // Leading blanks for (let i = 0; i < leadBlanks; i++) { const c = document.createElement('div'); c.className = 'cal-cell cal-cell--off'; grid.appendChild(c); } // Days for (let day = 1; day <= last.getDate(); day++) { const d = new Date(year, month, day); const iso = isoOf(d); const inRange = iso >= goalRange.min && iso <= goalRange.max; const isFuture = iso > today; const cell = document.createElement('button'); cell.type = 'button'; cell.className = 'cal-cell'; if (!inRange) cell.classList.add('cal-cell--out-of-range'); if (isFuture) cell.classList.add('cal-cell--future'); if (iso === today) cell.classList.add('cal-cell--today'); cell.dataset.iso = iso; const dateEl = document.createElement('span'); dateEl.className = 'cal-cell__date'; dateEl.textContent = String(day); cell.appendChild(dateEl); if (inRange) { const dotsWrap = document.createElement('div'); dotsWrap.className = 'cal-cell__grid'; // 4 dots: rows = goals (orderedGoals[0], orderedGoals[1]); cols = users (ray, cer) // Order in DOM: row1col1, row1col2, row2col1, row2col2 const todays = byDate.get(iso) || []; const has = (user, goalId) => todays.some((c) => c.user === user && c.goal_id === goalId && c.completed); const layout = [ ['ray', orderedGoals[0]?.id], ['cer', orderedGoals[0]?.id], ['ray', orderedGoals[1]?.id], ['cer', orderedGoals[1]?.id], ]; for (const [user, goalId] of layout) { const dot = document.createElement('span'); dot.className = `cal-cell__dot cal-cell__dot--${user}`; if (goalId && has(user, goalId)) dot.classList.add('cal-cell__dot--on'); dotsWrap.appendChild(dot); } cell.appendChild(dotsWrap); } if (inRange && !isFuture) { cell.addEventListener('click', () => onDayClick({ iso })); } else { cell.disabled = true; } grid.appendChild(cell); } // Trailing blanks for (let i = 0; i < trailBlanks; i++) { const c = document.createElement('div'); c.className = 'cal-cell cal-cell--off'; grid.appendChild(c); } root.appendChild(grid); } /* convenient util re-exports */ export { addDays, isoToDate, todayISO };