174 lines
5.5 KiB
JavaScript
174 lines
5.5 KiB
JavaScript
|
|
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 = `
|
||
|
|
<div class="cal-title">${monthName} ${year}</div>
|
||
|
|
<div class="cal-nav">
|
||
|
|
<button class="iconbtn" data-prev aria-label="Previous month"><</button>
|
||
|
|
<button class="iconbtn" data-today aria-label="Today">today</button>
|
||
|
|
<button class="iconbtn" data-next aria-label="Next month">></button>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
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 };
|