raycer/frontend/public/js/calendar.js

174 lines
5.5 KiB
JavaScript
Raw Permalink Normal View History

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">&lt;</button>
<button class="iconbtn" data-today aria-label="Today">today</button>
<button class="iconbtn" data-next aria-label="Next month">&gt;</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 };