Files
TimeTracker/app/static/calendar.js
T
Dries Peeters ef43eb858f Fix time entries showing as Event in calendar module
When the API returns a merged events list (single 'events' array with
item_type), the calendar module now splits by item_type so time entries
populate this.timeEntries and render with Time Entry category (clock
icon, time-entry color, correct modal and links).

- loadEvents(): detect merged response and split data.events by
  extendedProps.item_type into events, tasks, time_entries
- renderDayEvents(), renderWeekDayBlocks(), renderMonthCellEvents():
  use item_type for block/badge type and showEventDetails() so
  time entries show as Time Entry, not Event

Refs #517

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 20:21:52 +01:00

932 lines
43 KiB
JavaScript

/**
* Calendar functionality for TimeTracker
* Handles day, week, and month views with drag-and-drop support
*/
class Calendar {
constructor(options) {
this.viewType = options.viewType || 'month';
this.currentDate = new Date(options.currentDate || new Date());
this.container = document.getElementById('calendarContainer');
this.apiUrl = options.apiUrl;
this.events = [];
this.tasks = [];
this.timeEntries = [];
// Filters
this.showEvents = true;
this.showTasks = true;
this.showTimeEntries = true;
this.init();
}
init() {
this.setupEventListeners();
this.loadEvents();
this.updateViewLinks();
}
setupEventListeners() {
// View navigation
document.getElementById('todayBtn')?.addEventListener('click', () => {
this.currentDate = new Date();
this.loadEvents();
this.updateViewLinks();
});
document.getElementById('prevBtn')?.addEventListener('click', () => {
this.navigatePrevious();
});
document.getElementById('nextBtn')?.addEventListener('click', () => {
this.navigateNext();
});
// Filters
document.getElementById('showEvents')?.addEventListener('change', (e) => {
this.showEvents = e.target.checked;
this.render();
});
document.getElementById('showTasks')?.addEventListener('change', (e) => {
this.showTasks = e.target.checked;
this.render();
});
document.getElementById('showTimeEntries')?.addEventListener('change', (e) => {
this.showTimeEntries = e.target.checked;
this.render();
});
// Save calendar colors
document.getElementById('saveCalendarColorsBtn')?.addEventListener('click', () => {
this.saveCalendarColors();
});
// Modal close
document.querySelectorAll('[data-dismiss="modal"]').forEach(btn => {
btn.addEventListener('click', () => {
const eventModal = document.getElementById('eventModal');
if (eventModal) {
eventModal.style.display = 'none';
eventModal.classList.remove('show');
}
});
});
}
navigatePrevious() {
switch (this.viewType) {
case 'day':
this.currentDate.setDate(this.currentDate.getDate() - 1);
break;
case 'week':
this.currentDate.setDate(this.currentDate.getDate() - 7);
break;
case 'month':
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
break;
}
this.loadEvents();
this.updateViewLinks();
}
navigateNext() {
switch (this.viewType) {
case 'day':
this.currentDate.setDate(this.currentDate.getDate() + 1);
break;
case 'week':
this.currentDate.setDate(this.currentDate.getDate() + 7);
break;
case 'month':
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
break;
}
this.loadEvents();
this.updateViewLinks();
}
async loadEvents() {
const { start, end } = this.getDateRange();
try {
const url = new URL(this.apiUrl, window.location.origin);
url.searchParams.append('start', start.toISOString());
url.searchParams.append('end', end.toISOString());
url.searchParams.append('include_tasks', 'true');
url.searchParams.append('include_time_entries', 'true');
const response = await fetch(url);
const data = await response.json();
// Build unified lists from calendar API (events, tasks, time_entries are separate).
// If API returns merged list (only data.events with item_type), split by item_type.
const rawEvents = data.events || [];
const rawTasks = data.tasks || [];
const rawTimeEntries = data.time_entries || [];
const typeColors = data.typeColors || (window.calendarData && window.calendarData.typeColors) || { event: '#3b82f6', task: '#f59e0b', time_entry: '#10b981' };
const getItemType = (e) => (e.extendedProps && e.extendedProps.item_type) || e.type || 'event';
let eventsForDisplay = rawEvents;
let tasksForDisplay = rawTasks;
let timeEntriesForDisplay = rawTimeEntries;
if (rawTimeEntries.length === 0 && rawTasks.length === 0 && rawEvents.length > 0) {
// Merged response: split by item_type so time entries show as Time Entry category
eventsForDisplay = rawEvents.filter(e => getItemType(e) === 'event');
tasksForDisplay = rawEvents.filter(e => getItemType(e) === 'task');
timeEntriesForDisplay = rawEvents.filter(e => getItemType(e) === 'time_entry');
}
this.events = eventsForDisplay.map(e => ({
...e,
color: e.color != null ? e.color : typeColors.event,
extendedProps: { ...(e.extendedProps || {}), ...e, item_type: (e.extendedProps && e.extendedProps.item_type) || 'event' }
}));
this.tasks = tasksForDisplay.map(t => {
const start = t.dueDate != null ? t.dueDate : t.start;
const end = t.dueDate != null ? t.dueDate : t.end;
return {
id: t.id,
title: t.title,
start: start,
end: end,
color: t.color != null ? t.color : typeColors.task,
extendedProps: { ...(t.extendedProps || {}), ...t, item_type: 'task' }
};
});
this.timeEntries = timeEntriesForDisplay.map(e => ({
...e,
color: e.color != null ? e.color : typeColors.time_entry,
extendedProps: { ...(e.extendedProps || {}), ...e, item_type: (e.extendedProps && e.extendedProps.item_type) || 'time_entry' }
}));
this.render();
// Update color inputs and legend from API typeColors if present
if (data.typeColors) {
const eventsInput = document.getElementById('calendarColorEvents');
const tasksInput = document.getElementById('calendarColorTasks');
const entriesInput = document.getElementById('calendarColorTimeEntries');
if (eventsInput) eventsInput.value = data.typeColors.event || eventsInput.value;
if (tasksInput) tasksInput.value = data.typeColors.task || tasksInput.value;
if (entriesInput) entriesInput.value = data.typeColors.time_entry || entriesInput.value;
}
} catch (error) {
console.error('Error loading events:', error);
this.container.innerHTML = '<div class="text-center text-red-500 py-12">Error loading calendar data</div>';
}
}
async saveCalendarColors() {
const prefsUrl = window.calendarData?.preferencesUrl;
const csrfToken = window.calendarData?.csrfToken;
if (!prefsUrl || !csrfToken) return;
const eventsInput = document.getElementById('calendarColorEvents');
const tasksInput = document.getElementById('calendarColorTasks');
const entriesInput = document.getElementById('calendarColorTimeEntries');
const payload = {
calendar_color_events: eventsInput?.value || null,
calendar_color_tasks: tasksInput?.value || null,
calendar_color_time_entries: entriesInput?.value || null
};
try {
const resp = await fetch(prefsUrl, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (resp.ok && data.success) {
this.loadEvents();
} else {
alert(data.error || 'Failed to save calendar colors');
}
} catch (e) {
console.error('Save calendar colors failed', e);
alert('Failed to save calendar colors');
}
}
getDateRange() {
let start, end;
switch (this.viewType) {
case 'day':
start = new Date(this.currentDate);
start.setHours(0, 0, 0, 0);
end = new Date(this.currentDate);
end.setHours(23, 59, 59, 999);
break;
case 'week':
const day = this.currentDate.getDay();
const diff = this.currentDate.getDate() - day + (day === 0 ? -6 : 1); // Monday as start
start = new Date(this.currentDate);
start.setDate(diff);
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
break;
case 'month':
start = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
end = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0, 23, 59, 59, 999);
break;
}
return { start, end };
}
updateViewLinks() {
const viewUrl = window.calendarData?.viewUrl;
if (!viewUrl) return;
const y = this.currentDate.getFullYear();
const m = String(this.currentDate.getMonth() + 1).padStart(2, '0');
const d = String(this.currentDate.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${d}`;
['day', 'week', 'month'].forEach(view => {
const el = document.querySelector(`[data-view="${view}"]`);
if (el) el.href = `${viewUrl}?view=${view}&date=${dateStr}`;
});
}
render() {
this.updateTitle();
switch (this.viewType) {
case 'day':
this.renderDayView();
break;
case 'week':
this.renderWeekView();
break;
case 'month':
this.renderMonthView();
break;
}
}
updateTitle() {
const titleEl = document.getElementById('calendarTitle');
if (!titleEl) return;
const options = { month: 'long', year: 'numeric' };
switch (this.viewType) {
case 'day':
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'
});
break;
case 'week':
const { start, end } = this.getDateRange();
titleEl.textContent = `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
break;
case 'month':
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, options);
break;
}
}
renderDayView() {
const html = `
<div class="calendar-day-view">
<div class="time-slots">
${this.renderTimeSlots()}
</div>
<div class="events-column">
${this.renderDayEvents()}
</div>
</div>
`;
this.container.innerHTML = html;
}
renderTimeSlots() {
const slots = [];
for (let hour = 0; hour < 24; hour++) {
let time;
if (window.userPrefs && window.userPrefs.timeFormat === '12h') {
const ampm = hour >= 12 ? 'PM' : 'AM';
const h12 = hour % 12 || 12;
time = `${h12}:00 ${ampm}`;
} else {
time = `${hour.toString().padStart(2, '0')}:00`;
}
slots.push(`<div class="time-slot" data-hour="${hour}">${time}</div>`);
}
return slots.join('');
}
/**
* Assign column indices to items so overlapping items (by time range) get different columns.
* Items must have startMs and endMs. Mutates and returns items with .column and .totalColumns.
*/
assignOverlapColumnsByTime(items) {
if (items.length === 0) return items;
const sorted = items.slice().sort((a, b) => a.startMs - b.startMs);
const maxCols = 8;
const columnsEnd = [];
sorted.forEach(item => {
const start = item.startMs;
const end = item.endMs;
let col = 0;
while (col < columnsEnd.length && columnsEnd[col] > start) col++;
if (col >= maxCols) col = maxCols - 1;
item.column = col;
if (col === columnsEnd.length) columnsEnd.push(end);
else columnsEnd[col] = end;
});
const totalColumns = Math.min(columnsEnd.length, maxCols) || 1;
sorted.forEach(item => { item.totalColumns = totalColumns; });
return sorted;
}
/**
* Assign column indices to items that have top/height (%). Overlapping vertical ranges get different columns.
*/
assignOverlapColumnsByPosition(items) {
if (items.length === 0) return items;
const sorted = items.slice().sort((a, b) => a.top - b.top);
const maxCols = 8;
const columnsEnd = [];
sorted.forEach(item => {
const start = item.top;
const end = item.top + item.height;
let col = 0;
while (col < columnsEnd.length && columnsEnd[col] > start) col++;
if (col >= maxCols) col = maxCols - 1;
item.column = col;
if (col === columnsEnd.length) columnsEnd.push(end);
else columnsEnd[col] = end;
});
const totalColumns = Math.min(columnsEnd.length, maxCols) || 1;
sorted.forEach(item => { item.totalColumns = totalColumns; });
return sorted;
}
/** Return CSS left/width for a column (gap 1% between columns). */
columnStyle(col, totalCols) {
if (totalCols <= 1) return { left: '0', width: '100%' };
const gapPct = 1;
const widthPct = (100 - (totalCols - 1) * gapPct) / totalCols;
const leftPct = col * (widthPct + gapPct);
return { left: leftPct + '%', width: widthPct + '%' };
}
renderDayEvents() {
const dayStart = new Date(this.currentDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(this.currentDate);
dayEnd.setHours(23, 59, 59, 999);
const timedItems = [];
if (this.showEvents) {
this.events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = event.end ? new Date(event.end) : null;
if (eventStart > dayEnd || (eventEnd && eventEnd < dayStart)) return;
const effectiveStart = eventStart < dayStart ? dayStart : eventStart;
const effectiveEnd = eventEnd ? (eventEnd > dayEnd ? dayEnd : eventEnd) : new Date(effectiveStart.getTime() + 60 * 60 * 1000);
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60);
const heightMinutes = Math.max(30, Math.min(1440 - startMinutes, durationMinutes));
const blockType = (event.extendedProps && event.extendedProps.item_type) || 'event';
const startTime = window.formatUserTime(effectiveStart);
const endTimeStr = eventEnd ? window.formatUserTime(effectiveEnd) : '';
const notesText = event.notes || (event.extendedProps && event.extendedProps.notes) || '';
const notes = notesText ? `<br><small class="text-xs">${this.escapeHtml(notesText)}</small>` : '';
timedItems.push({
type: blockType,
startMs: effectiveStart.getTime(),
endMs: effectiveEnd.getTime(),
event,
entry: event,
topPosition: startMinutes,
heightMinutes,
time: startTime,
title: this.escapeHtml(event.title),
color: event.color || (blockType === 'time_entry' ? '#10b981' : '#3b82f6'),
startTime,
endTimeStr,
entryEnd: eventEnd || null,
notes
});
});
}
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
const entryEnd = entry.end ? new Date(entry.end) : null;
const entryEndForDay = entryEnd || new Date(entryStart.getTime() + 30 * 60 * 1000);
const effectiveStart = entryStart < dayStart ? dayStart : entryStart;
const effectiveEnd = entryEndForDay > dayEnd ? dayEnd : entryEndForDay;
if (effectiveStart > dayEnd || effectiveEnd < dayStart) return;
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
let heightMinutes;
if (entryEnd) {
const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60);
heightMinutes = Math.max(30, Math.min(1440 - startMinutes, durationMinutes));
} else {
heightMinutes = Math.min(30, 1440 - startMinutes);
}
const startTime = window.formatUserTime(effectiveStart);
const endTimeStr = entryEnd ? window.formatUserTime(effectiveEnd) : '';
const notesText = entry.notes || entry.extendedProps?.notes || '';
const notes = notesText ? `<br><small class="text-xs">${this.escapeHtml(notesText)}</small>` : '';
const entryColor = entry.color || '#10b981';
timedItems.push({
type: 'time_entry',
startMs: effectiveStart.getTime(),
endMs: effectiveEnd.getTime(),
entry,
topPosition: startMinutes,
heightMinutes,
startTime,
endTimeStr,
entryEnd,
title: this.escapeHtml(entry.title),
notes,
color: entryColor
});
});
}
this.assignOverlapColumnsByTime(timedItems);
let html = '<div class="day-events-container">';
timedItems.forEach(item => {
const { left, width } = this.columnStyle(item.column, item.totalColumns);
const style = `left: ${left}; width: ${width}; top: ${item.topPosition}px; height: ${item.heightMinutes}px;`;
const detailType = item.type;
const detailId = item.event ? item.event.id : item.entry.id;
if (item.type === 'event') {
html += `
<div class="event-card event" data-id="${item.event.id}" data-type="event"
style="border-left-color: ${item.color}; ${style}"
onclick="window.calendar.showEventDetails(${item.event.id}, '${detailType}', event)">
<i class="fas fa-calendar mr-2 text-blue-600 dark:text-blue-400"></i>
<strong>${item.title}</strong>
<br><small>${item.time}</small>
</div>
`;
} else if (item.type === 'time_entry') {
const durationText = item.entryEnd != null ? `${item.startTime} - ${item.endTimeStr}` : (item.time ? `${item.time} (active)` : '');
const notes = item.notes != null ? item.notes : '';
html += `
<div class="event-card time_entry" data-id="${detailId}" data-type="time_entry"
style="border-left-color: ${item.color}; ${style}"
onclick="window.calendar.showEventDetails(${detailId}, 'time_entry', event)">
⏱ <strong>${item.title}</strong>
<br><small>${durationText}</small>
${notes}
</div>
`;
}
});
if (this.showTasks) {
this.tasks.forEach(task => {
const taskTitle = this.escapeHtml(task.title);
const taskColor = task.color || '#f59e0b';
const priorityIcons = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
const priorityIcon = priorityIcons[task.extendedProps?.priority] || '📋';
html += `
<div class="event-card task" data-id="${task.id}" data-type="task" style="border-left-color: ${taskColor};" onclick="window.calendar.showEventDetails(${task.id}, 'task', event)">
${priorityIcon} <strong>${taskTitle}</strong>
<br><small>Due: ${task.start}</small>
<br><small class="text-xs">Status: ${task.extendedProps?.status || 'Unknown'}</small>
</div>
`;
});
}
html += '</div>';
return html;
}
renderWeekView() {
const { start } = this.getDateRange();
const days = [];
for (let i = 0; i < 7; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
days.push(day);
}
const timeSlots = Array.from({ length: 24 }, (_, h) => {
let label;
if (window.userPrefs && window.userPrefs.timeFormat === '12h') {
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
label = `${h12}:00 ${ampm}`;
} else {
label = `${h.toString().padStart(2, '0')}:00`;
}
return `<div class="week-time-slot">${label}</div>`;
}).join('');
const dayColumns = days.map(day => {
const blocks = this.renderWeekDayBlocks(day);
return `<div class="week-day-column" data-date="${day.toISOString().split('T')[0]}"><div class="week-day-blocks">${blocks}</div></div>`;
}).join('');
const dayHeaders = days.map(day => {
const isToday = this.isToday(day);
return `<div class="week-day-header-cell ${isToday ? 'today' : ''}">${day.toLocaleDateString(undefined, { weekday: 'short' })} ${window.formatUserDate(day)}</div>`;
}).join('');
this.container.innerHTML = `
<div class="calendar-week-view">
<div class="week-view-header">
<div class="week-time-header"></div>
${dayHeaders}
</div>
<div class="week-view-body">
<div class="week-time-column">${timeSlots}</div>
${dayColumns}
</div>
</div>
`;
}
/**
* Render all event/time-entry blocks for one day as whole blocks (top + height in px), not per-hour.
*/
renderWeekDayBlocks(day) {
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
const timedItems = [];
if (this.showEvents) {
this.events.forEach(event => {
if (event.allDay) return;
const eventStart = new Date(event.start);
const eventEnd = event.end ? new Date(event.end) : null;
if (eventStart > dayEnd || (eventEnd && eventEnd < dayStart)) return;
const effectiveStart = eventStart < dayStart ? dayStart : eventStart;
const effectiveEnd = eventEnd ? (eventEnd > dayEnd ? dayEnd : eventEnd) : new Date(effectiveStart.getTime() + 60 * 60 * 1000);
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60);
const heightMinutes = Math.max(30, Math.min(1440 - startMinutes, durationMinutes));
const timeStr = window.formatUserTime(effectiveStart);
const blockType = (event.extendedProps && event.extendedProps.item_type) || 'event';
const endTimeStr = eventEnd ? window.formatUserTime(effectiveEnd) : '';
const durationText = eventEnd ? `${timeStr} - ${endTimeStr}` : timeStr;
timedItems.push({
type: blockType,
startMs: effectiveStart.getTime(),
endMs: effectiveEnd.getTime(),
event,
entry: event,
topPosition: startMinutes,
heightMinutes,
title: this.escapeHtml(event.title),
color: event.color || (blockType === 'time_entry' ? '#10b981' : '#3b82f6'),
timeStr,
startTime: timeStr,
endTimeStr,
entryEnd: eventEnd
});
});
}
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
const entryEnd = entry.end ? new Date(entry.end) : null;
const entryEndForDay = entryEnd || new Date(entryStart.getTime() + 30 * 60 * 1000);
const effectiveStart = entryStart < dayStart ? dayStart : entryStart;
const effectiveEnd = entryEndForDay > dayEnd ? dayEnd : entryEndForDay;
if (effectiveStart > dayEnd || effectiveEnd < dayStart) return;
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
let heightMinutes;
if (entryEnd) {
const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60);
heightMinutes = Math.max(30, Math.min(1440 - startMinutes, durationMinutes));
} else {
heightMinutes = Math.min(30, 1440 - startMinutes);
}
const startTime = window.formatUserTime(effectiveStart);
const endTimeStr = entryEnd ? window.formatUserTime(effectiveEnd) : '';
const entryColor = entry.color || '#10b981';
timedItems.push({
type: 'time_entry',
startMs: effectiveStart.getTime(),
endMs: effectiveEnd.getTime(),
entry,
topPosition: startMinutes,
heightMinutes,
title: this.escapeHtml(entry.title),
startTime,
endTimeStr,
entryEnd,
color: entryColor
});
});
}
if (this.showTasks) {
this.tasks.forEach(task => {
const taskDate = new Date(task.start);
if (taskDate.toDateString() !== day.toDateString()) return;
const dueMinutes = 9 * 60;
const taskColor = task.color || '#f59e0b';
timedItems.push({
type: 'task',
startMs: dayStart.getTime() + dueMinutes * 60 * 1000,
endMs: dayStart.getTime() + (dueMinutes + 30) * 60 * 1000,
task,
topPosition: dueMinutes,
heightMinutes: 30,
title: this.escapeHtml(task.title),
color: taskColor
});
});
}
this.assignOverlapColumnsByTime(timedItems);
let html = '';
timedItems.forEach(item => {
const { left, width } = this.columnStyle(item.column, item.totalColumns);
const style = `left: ${left}; width: ${width}; top: ${item.topPosition}px; height: ${item.heightMinutes}px;`;
if (item.type === 'event') {
html += `<div class="week-event-block event" data-id="${item.event.id}" data-type="event" style="border-left-color: ${item.color}; ${style}" onclick="window.calendar.showEventDetails(${item.event.id}, '${item.type}', event)" title="${item.title} (${item.timeStr})"><i class="fas fa-calendar mr-1"></i><strong>${item.title}</strong><br><small>${item.timeStr}</small></div>`;
} else if (item.type === 'time_entry') {
const durationText = (item.entryEnd != null && item.startTime != null) ? `${item.startTime} - ${item.endTimeStr}` : (item.startTime || item.timeStr || '');
const blockId = (item.entry && item.entry.id) != null ? item.entry.id : item.event.id;
html += `<div class="week-event-block time_entry" data-id="${blockId}" data-type="time_entry" style="border-left-color: ${item.color}; ${style}" title="${item.title}" onclick="window.calendar.showEventDetails(${blockId}, 'time_entry', event)"><i class="fas fa-clock mr-1"></i><strong>${item.title}</strong><br><small>${durationText}</small></div>`;
} else {
html += `<div class="week-event-block task" data-id="${item.task.id}" data-type="task" style="border-left-color: ${item.color}; ${style}" onclick="window.calendar.showEventDetails(${item.task.id}, 'task', event); event.stopPropagation();" title="${item.title}">\uD83D\uDCCB ${item.title}</div>`;
}
});
return html;
}
renderMonthView() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1));
let html = '<div class="calendar-month-view"><table class="month-table"><thead><tr>';
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
weekdays.forEach(day => {
html += `<th>${day}</th>`;
});
html += '</tr></thead><tbody>';
const currentDate = new Date(startDate);
for (let week = 0; week < 6; week++) {
html += '<tr>';
for (let day = 0; day < 7; day++) {
const isCurrentMonth = currentDate.getMonth() === month;
const isToday = this.isToday(currentDate);
html += `<td class="month-cell ${!isCurrentMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}" data-date="${currentDate.toISOString()}">`;
html += `<div class="date-number">${currentDate.getDate()}</div>`;
html += this.renderMonthCellEvents(currentDate);
html += '</td>';
currentDate.setDate(currentDate.getDate() + 1);
}
html += '</tr>';
}
html += '</tbody></table></div>';
this.container.innerHTML = html;
// Add click handlers for cells
this.container.querySelectorAll('.month-cell').forEach(cell => {
cell.addEventListener('click', (e) => {
if (e.target.classList.contains('month-cell') || e.target.classList.contains('date-number')) {
const date = new Date(cell.dataset.date);
window.location.href = `${window.calendarData.newEventUrl}?date=${date.toISOString().split('T')[0]}`;
}
});
});
}
renderMonthCellEvents(day) {
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
let html = '<div class="month-events">';
let count = 0;
const maxDisplay = 3;
// Events (and any time_entry items that ended up in this.events - use item_type for badge and modal)
if (this.showEvents) {
this.events.forEach(event => {
const eventStart = new Date(event.start);
if (eventStart >= dayStart && eventStart <= dayEnd) {
if (count < maxDisplay) {
const eventTitle = this.escapeHtml(event.title);
const blockType = (event.extendedProps && event.extendedProps.item_type) || 'event';
const eventColor = event.color || (blockType === 'time_entry' ? '#10b981' : '#3b82f6');
const badgeIcon = blockType === 'time_entry' ? '⏱' : '📅';
const badgeClass = blockType === 'time_entry' ? 'event-badge time-entry-badge' : 'event-badge';
html += `<div class="${badgeClass}" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, '${blockType}', event); event.stopPropagation();" title="${eventTitle}">${badgeIcon} ${eventTitle}</div>`;
}
count++;
}
});
}
// Tasks
if (this.showTasks) {
this.tasks.forEach(task => {
const taskDate = new Date(task.start);
if (taskDate.toDateString() === day.toDateString()) {
if (count < maxDisplay) {
const taskTitle = this.escapeHtml(task.title);
const taskColor = task.color || '#f59e0b';
html += `<div class="event-badge task-badge" style="background-color: ${taskColor}" onclick="window.calendar.showEventDetails(${task.id}, 'task', event); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
}
count++;
}
});
}
// Time entries
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
if (entryStart >= dayStart && entryStart <= dayEnd) {
if (count < maxDisplay) {
const entryTitle = this.escapeHtml(entry.title);
const entryColor = entry.color || '#10b981';
html += `<div class="event-badge time-entry-badge" style="background-color: ${entryColor}" onclick="window.calendar.showEventDetails(${entry.id}, 'time_entry', event); event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
}
count++;
}
});
}
if (count > maxDisplay) {
html += `<div class="event-badge-more">+${count - maxDisplay} more</div>`;
}
html += '</div>';
return html;
}
isToday(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
showEventDetails(id, type, clickEvent) {
const modal = document.getElementById('eventModal');
const modalTitle = document.querySelector('#eventModal .modal-title');
const bodyEl = document.getElementById('eventDetails');
const goToBtn = document.getElementById('eventModalGoToBtn');
const editEventBtn = document.getElementById('editEventBtn');
const deleteEventBtn = document.getElementById('deleteEventBtn');
if (!modal || !bodyEl) return;
const idStr = String(id);
let item = null;
if (type === 'event') {
item = this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'event');
} else if (type === 'task') {
item = this.tasks.find(t => String(t.id) === idStr) ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'task');
} else if (type === 'time_entry') {
item = this.timeEntries.find(e => String(e.id) === idStr) ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'time_entry') ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && (e.extendedProps.type === 'time_entry' || e.extendedProps.duration_hours != null || e.extendedProps.source != null))) ||
this.events.find(e => String(e.id) === idStr);
}
if (!item) {
bodyEl.innerHTML = '<p class="text-muted">Details not available.</p>';
if (modalTitle) modalTitle.textContent = type === 'event' ? 'Event' : type === 'task' ? 'Task' : 'Time Entry';
let detailUrl = type === 'event' ? `/calendar/event/${id}` : type === 'task' ? `/tasks/${id}` : `/timer/edit/${id}`;
if (goToBtn) { goToBtn.href = detailUrl; goToBtn.style.display = ''; }
if (editEventBtn) { editEventBtn.href = detailUrl; editEventBtn.style.display = ''; }
if (deleteEventBtn) deleteEventBtn.style.display = 'none';
modal.style.display = 'block';
modal.classList.add('show');
this._positionModalNearClick(modal, clickEvent);
return;
}
const props = item.extendedProps || {};
const formatDate = (d) => {
if (!d) return '—';
const dt = new Date(d);
return window.formatUserDateTime(dt);
};
// Use effective type for link: registered time (time entries) must go to /timer/edit/, not /calendar/event/
let effectiveType = props.item_type || props.type || type;
const looksLikeTimeEntry = props.duration_hours != null || props.source != null || (props.projectId != null && item.start && item.end && !item.allDay);
if (effectiveType === 'event' && (props.item_type === 'time_entry' || props.type === 'time_entry' || looksLikeTimeEntry)) {
effectiveType = 'time_entry';
}
if (effectiveType !== 'task' && effectiveType !== 'event' && looksLikeTimeEntry) {
effectiveType = 'time_entry';
}
let detailUrl = '#';
let titleLabel = 'Event';
if (effectiveType === 'event') {
detailUrl = `/calendar/event/${item.id}`;
titleLabel = 'Event Details';
} else if (effectiveType === 'task') {
detailUrl = `/tasks/${item.id}`;
titleLabel = 'Task';
} else {
detailUrl = `/timer/edit/${item.id}`;
titleLabel = 'Time Entry Details';
}
if (modalTitle) modalTitle.textContent = titleLabel;
if (goToBtn) { goToBtn.href = detailUrl; goToBtn.style.display = ''; }
if (editEventBtn) { editEventBtn.href = detailUrl; editEventBtn.innerHTML = '<i class="fas fa-external-link-alt mr-2"></i>Go to all details'; editEventBtn.style.display = ''; }
if (deleteEventBtn) deleteEventBtn.style.display = 'none';
if (type === 'task') {
const dueStr = item.start ? formatDate(item.start) : (props.dueDate || '—');
bodyEl.innerHTML = `
<div class="event-detail-row"><div class="event-detail-label">Title</div><div class="event-detail-value">${this.escapeHtml(item.title || props.title || '')}</div></div>
<div class="event-detail-row"><div class="event-detail-label">Due</div><div class="event-detail-value">${dueStr}</div></div>
${props.status ? `<div class="event-detail-row"><div class="event-detail-label">Status</div><div class="event-detail-value">${this.escapeHtml(props.status)}</div></div>` : ''}
${props.project_name ? `<div class="event-detail-row"><div class="event-detail-label">Project</div><div class="event-detail-value">${this.escapeHtml(props.project_name)}</div></div>` : ''}
`;
} else {
const startStr = item.start ? formatDate(item.start) : '—';
const endStr = item.end ? formatDate(item.end) : '—';
const duration = (props.duration_hours != null) ? Number(props.duration_hours).toFixed(2) : '—';
bodyEl.innerHTML = `
<div class="event-detail-row"><div class="event-detail-label">Project</div><div class="event-detail-value">${this.escapeHtml(props.project_name || '')}</div></div>
${props.task_name ? `<div class="event-detail-row"><div class="event-detail-label">Task</div><div class="event-detail-value">${this.escapeHtml(props.task_name)}</div></div>` : ''}
<div class="event-detail-row"><div class="event-detail-label">Start</div><div class="event-detail-value">${startStr}</div></div>
<div class="event-detail-row"><div class="event-detail-label">End</div><div class="event-detail-value">${endStr}</div></div>
<div class="event-detail-row"><div class="event-detail-label">Duration</div><div class="event-detail-value">${duration} hours</div></div>
${props.notes ? `<div class="event-detail-row"><div class="event-detail-label">Notes</div><div class="event-detail-value">${this.escapeHtml(props.notes)}</div></div>` : ''}
${props.tags ? `<div class="event-detail-row"><div class="event-detail-label">Tags</div><div class="event-detail-value">${this.escapeHtml(props.tags)}</div></div>` : ''}
`;
}
modal.style.display = 'block';
modal.classList.add('show');
this._positionModalNearClick(modal, clickEvent);
}
_positionModalNearClick(modal, clickEvent) {
const contentEl = modal && modal.querySelector('.modal-dialog');
if (!contentEl) return;
if (!clickEvent || !clickEvent.clientX) {
contentEl.style.position = '';
contentEl.style.left = '';
contentEl.style.top = '';
return;
}
const pad = 16;
const x = clickEvent.clientX;
const y = clickEvent.clientY;
contentEl.style.position = 'fixed';
requestAnimationFrame(() => {
const rect = contentEl.getBoundingClientRect();
let left = x + pad;
let top = y + pad;
if (left + rect.width > window.innerWidth - pad) left = window.innerWidth - rect.width - pad;
if (top + rect.height > window.innerHeight - pad) top = window.innerHeight - rect.height - pad;
if (left < pad) left = pad;
if (top < pad) top = pad;
contentEl.style.left = left + 'px';
contentEl.style.top = top + 'px';
});
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text ? text.replace(/[&<>"']/g, m => map[m]) : '';
}
}
// Initialize calendar when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.calendarData !== 'undefined') {
window.calendar = new Calendar({
viewType: window.calendarData.viewType,
currentDate: window.calendarData.currentDate,
apiUrl: window.calendarData.apiUrl
});
}
});