Files
TimeTracker/templates/timer/calendar.html
Dries Peeters 3f4b273b18 feat: Add command palette, enhance calendar, and improve i18n
This commit implements three major feature enhancements to improve user
productivity and experience:

COMMAND PALETTE IMPROVEMENTS:
- Add '?' key as intuitive shortcut to open command palette
- Maintain backward compatibility with Ctrl+K/Cmd+K
- Enhance visual design with modern styling and smooth animations
- Add 3D effect to keyboard badges and improved dark mode support
- Update first-time user hints and tooltips
- Improve input field detection to prevent conflicts

CALENDAR REDESIGN:
- Implement comprehensive drag-and-drop for moving/resizing events
- Add multiple calendar views (Day/Week/Month/Agenda)
- Create advanced filtering by project, task, and tags
- Build full-featured event creation modal with validation
- Add calendar export functionality (iCal and CSV formats)
- Implement color-coded project visualization (10 distinct colors)
- Create dedicated calendar.css with professional styling
- Add recurring events management UI
- Optimize API with indexed queries and proper filtering

TRANSLATION SYSTEM ENHANCEMENTS:
- Update all 6 language files (EN/DE/NL/FR/IT/FI) with 150+ strings
- Improve language switcher UI with globe icon and visual indicators
- Fix hardcoded strings in dashboard and base templates
- Add check mark for currently selected language
- Enhance accessibility with proper ARIA labels
- Style language switcher with hover effects and smooth transitions

DOCUMENTATION:
- Add COMMAND_PALETTE_IMPROVEMENTS.md and COMMAND_PALETTE_USAGE.md
- Create CALENDAR_IMPROVEMENTS_SUMMARY.md and CALENDAR_FEATURES_README.md
- Add TRANSLATION_IMPROVEMENTS_SUMMARY.md and TRANSLATION_SYSTEM.md
- Update HIGH_IMPACT_FEATURES.md with implementation details

All features are production-ready, fully tested, responsive, and maintain
backward compatibility.
2025-10-07 19:00:07 +02:00

881 lines
32 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Calendar') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='calendar.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<div class="calendar-header">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-calendar-alt me-2 text-primary"></i>{{ _('Calendar') }}
</h5>
<div class="calendar-controls">
<!-- View Controls -->
<button class="btn btn-sm btn-outline-secondary" id="todayBtn">
<i class="fas fa-calendar-day me-1"></i>{{ _('Today') }}
</button>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" id="dayBtn">{{ _('Day') }}</button>
<button class="btn btn-sm btn-outline-primary active" id="weekBtn">{{ _('Week') }}</button>
<button class="btn btn-sm btn-outline-primary" id="monthBtn">{{ _('Month') }}</button>
<button class="btn btn-sm btn-outline-primary" id="agendaBtn">{{ _('Agenda') }}</button>
</div>
<!-- Action Buttons -->
<button class="btn btn-sm btn-primary" id="newEventBtn">
<i class="fas fa-plus me-1"></i>{{ _('New Event') }}
</button>
<button class="btn btn-sm btn-outline-secondary" id="manageRecurringBtn">
<i class="fas fa-redo me-1"></i>{{ _('Recurring') }}
</button>
<!-- Export Dropdown -->
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="exportDropdown" data-bs-toggle="dropdown">
<i class="fas fa-download me-1"></i>{{ _('Export') }}
</button>
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
<li><a class="dropdown-item" href="#" id="exportIcal"><i class="fas fa-calendar me-2"></i>{{ _('iCal Format') }}</a></li>
<li><a class="dropdown-item" href="#" id="exportCsv"><i class="fas fa-file-csv me-2"></i>{{ _('CSV Format') }}</a></li>
</ul>
</div>
</div>
</div>
<!-- Filters Row -->
<div class="calendar-filters mt-3">
<select id="filterProject" class="form-select form-select-sm calendar-filter-project">
<option value="">{{ _('All Projects') }}</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
{% endfor %}
</select>
<select id="filterTask" class="form-select form-select-sm calendar-filter-task">
<option value="">{{ _('All Tasks') }}</option>
</select>
<input type="text" id="filterTags" class="form-control form-control-sm calendar-filter-tags" placeholder="{{ _('Filter by tags...') }}">
<button class="btn btn-sm btn-outline-danger" id="clearFilters">
<i class="fas fa-times me-1"></i>{{ _('Clear') }}
</button>
<select id="assignProject" class="form-select form-select-sm calendar-assign">
<option value="">{{ _('Assign to project for new events...') }}</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
{% endfor %}
</select>
</div>
</div>
<div class="card-body position-relative">
<!-- Loading Indicator -->
<div class="calendar-loading" id="calendarLoading">
<div class="calendar-spinner"></div>
</div>
<!-- Calendar View -->
<div id="calendarView">
<div id="calendar"></div>
</div>
<!-- Agenda View -->
<div id="agendaView" class="agenda-view">
<div id="agendaContent"></div>
</div>
<!-- Legend -->
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-color" style="background: #3b82f6;"></div>
<span>{{ _('Project 1') }}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ef4444;"></div>
<span>{{ _('Project 2') }}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #10b981;"></div>
<span>{{ _('Project 3') }}</span>
</div>
<div class="legend-item">
<i class="fas fa-info-circle me-1 text-muted"></i>
<span class="text-muted small">{{ _('Colors assigned by project') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Event Creation Modal -->
<div class="event-modal" id="eventCreateModal">
<div class="event-modal-content">
<div class="event-modal-header">
<h3><i class="fas fa-plus-circle me-2 text-primary"></i>{{ _('Create Time Entry') }}</h3>
<button class="event-modal-close" onclick="closeModal('eventCreateModal')">&times;</button>
</div>
<form id="eventCreateForm">
<div class="event-modal-body">
<div class="mb-3">
<label class="form-label">{{ _('Project') }} <span class="text-danger">*</span></label>
<select class="form-select" id="createProject" required>
<option value="">{{ _('Select a project...') }}</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">{{ _('Task') }}</label>
<select class="form-select" id="createTask">
<option value="">{{ _('No task') }}</option>
</select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{{ _('Start Date') }} <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="createStartDate" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{{ _('Start Time') }} <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="createStartTime" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{{ _('End Date') }} <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="createEndDate" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{{ _('End Time') }} <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="createEndTime" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ _('Notes') }}</label>
<textarea class="form-control" id="createNotes" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">{{ _('Tags') }}</label>
<input type="text" class="form-control" id="createTags" placeholder="{{ _('Comma-separated tags') }}">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="createBillable" checked>
<label class="form-check-label" for="createBillable">
{{ _('Billable') }}
</label>
</div>
</div>
<div class="event-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('eventCreateModal')">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>{{ _('Create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Event Detail/Edit Modal -->
<div class="event-modal" id="eventDetailModal">
<div class="event-modal-content">
<div class="event-modal-header">
<h3><i class="fas fa-clock me-2 text-primary"></i>{{ _('Time Entry Details') }}</h3>
<button class="event-modal-close" onclick="closeModal('eventDetailModal')">&times;</button>
</div>
<div class="event-modal-body">
<div class="event-detail" id="eventDetailContent">
<!-- Content will be populated by JavaScript -->
</div>
</div>
<div class="event-modal-footer">
<button type="button" class="btn btn-danger" id="deleteEventBtn">
<i class="fas fa-trash me-1"></i>{{ _('Delete') }}
</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('eventDetailModal')">{{ _('Close') }}</button>
<a href="#" class="btn btn-primary" id="editEventBtn">
<i class="fas fa-edit me-1"></i>{{ _('Edit') }}
</a>
</div>
</div>
</div>
<!-- Recurring Events Modal -->
<div class="event-modal" id="recurringModal">
<div class="event-modal-content">
<div class="event-modal-header">
<h3><i class="fas fa-redo me-2 text-primary"></i>{{ _('Recurring Time Blocks') }}</h3>
<button class="event-modal-close" onclick="closeModal('recurringModal')">&times;</button>
</div>
<div class="event-modal-body">
<p class="text-muted">{{ _('Manage your recurring time entry templates.') }}</p>
<div class="recurring-list" id="recurringList">
<!-- Content will be populated by JavaScript -->
</div>
</div>
<div class="event-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('recurringModal')">{{ _('Close') }}</button>
<button type="button" class="btn btn-primary" id="newRecurringBtn">
<i class="fas fa-plus me-1"></i>{{ _('New Recurring Block') }}
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const calendarEl = document.getElementById('calendar');
const projectSelect = document.getElementById('assignProject');
const filterProject = document.getElementById('filterProject');
const filterTask = document.getElementById('filterTask');
const filterTags = document.getElementById('filterTags');
const agendaView = document.getElementById('agendaView');
const calendarView = document.getElementById('calendarView');
let currentView = 'calendar';
let currentFilters = {
project_id: null,
task_id: null,
tags: null
};
// Initialize FullCalendar
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
headerToolbar: false,
selectable: true,
selectMirror: true,
editable: true,
nowIndicator: true,
firstDay: 1,
slotDuration: '00:30:00',
slotMinTime: '06:00:00',
slotMaxTime: '22:00:00',
height: 'auto',
eventResizableFromStart: true,
events: async (info, success, failure) => {
try {
showLoading(true);
let url = `/api/calendar/events?start=${encodeURIComponent(info.startStr)}&end=${encodeURIComponent(info.endStr)}`;
if (currentFilters.project_id) url += `&project_id=${currentFilters.project_id}`;
if (currentFilters.task_id) url += `&task_id=${currentFilters.task_id}`;
if (currentFilters.tags) url += `&tags=${encodeURIComponent(currentFilters.tags)}`;
const res = await fetch(url);
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Failed');
success(json.events || []);
} catch(e) {
showToast('{{ _("Failed to load events") }}', 'danger');
failure(e);
} finally {
showLoading(false);
}
},
select: function(selection) {
// Open create modal with pre-filled dates
const pid = projectSelect.value;
if (!pid) {
showToast('{{ _("Please select a project for new entries") }}', 'warning');
return;
}
document.getElementById('createProject').value = pid;
loadTasksForProject(pid, 'create');
const startDate = new Date(selection.start);
const endDate = new Date(selection.end);
document.getElementById('createStartDate').value = startDate.toISOString().split('T')[0];
document.getElementById('createStartTime').value = startDate.toTimeString().slice(0, 5);
document.getElementById('createEndDate').value = endDate.toISOString().split('T')[0];
document.getElementById('createEndTime').value = endDate.toTimeString().slice(0, 5);
openModal('eventCreateModal');
calendar.unselect();
},
eventClick: function(info) {
showEventDetails(info.event);
},
eventDrop: async function(info) {
await updateEventTime(info.event);
},
eventResize: async function(info) {
await updateEventTime(info.event);
}
});
calendar.render();
// View Controls
document.getElementById('todayBtn').addEventListener('click', () => calendar.today());
document.getElementById('dayBtn').addEventListener('click', () => {
calendar.changeView('timeGridDay');
setActiveView('calendar');
});
document.getElementById('weekBtn').addEventListener('click', () => {
calendar.changeView('timeGridWeek');
setActiveView('calendar');
});
document.getElementById('monthBtn').addEventListener('click', () => {
calendar.changeView('dayGridMonth');
setActiveView('calendar');
});
document.getElementById('agendaBtn').addEventListener('click', () => {
setActiveView('agenda');
renderAgendaView();
});
// New Event Button
document.getElementById('newEventBtn').addEventListener('click', () => {
const pid = projectSelect.value;
if (!pid) {
showToast('{{ _("Please select a project first") }}', 'warning');
return;
}
// Set default times to now and 1 hour from now
const now = new Date();
const later = new Date(now.getTime() + 60 * 60 * 1000);
document.getElementById('createProject').value = pid;
loadTasksForProject(pid, 'create');
document.getElementById('createStartDate').value = now.toISOString().split('T')[0];
document.getElementById('createStartTime').value = now.toTimeString().slice(0, 5);
document.getElementById('createEndDate').value = later.toISOString().split('T')[0];
document.getElementById('createEndTime').value = later.toTimeString().slice(0, 5);
openModal('eventCreateModal');
});
// Event Create Form
document.getElementById('eventCreateForm').addEventListener('submit', async (e) => {
e.preventDefault();
await createEvent();
});
document.getElementById('createProject').addEventListener('change', (e) => {
loadTasksForProject(e.target.value, 'create');
});
// Filter Project Change
filterProject.addEventListener('change', () => {
currentFilters.project_id = filterProject.value || null;
currentFilters.task_id = null; // Reset task filter
filterTask.value = '';
if (filterProject.value) {
loadTasksForProject(filterProject.value, 'filter');
} else {
filterTask.innerHTML = '<option value="">{{ _("All Tasks") }}</option>';
filterTask.disabled = true;
}
calendar.refetchEvents();
});
// Filter Task Change
filterTask.addEventListener('change', () => {
currentFilters.task_id = filterTask.value || null;
calendar.refetchEvents();
});
// Filter Tags
let tagsTimeout;
filterTags.addEventListener('input', () => {
clearTimeout(tagsTimeout);
tagsTimeout = setTimeout(() => {
currentFilters.tags = filterTags.value.trim() || null;
calendar.refetchEvents();
}, 500);
});
// Clear Filters
document.getElementById('clearFilters').addEventListener('click', () => {
filterProject.value = '';
filterTask.value = '';
filterTask.disabled = true;
filterTags.value = '';
currentFilters = { project_id: null, task_id: null, tags: null };
calendar.refetchEvents();
});
// Export Functions
document.getElementById('exportIcal').addEventListener('click', async (e) => {
e.preventDefault();
await exportCalendar('ical');
});
document.getElementById('exportCsv').addEventListener('click', async (e) => {
e.preventDefault();
await exportCalendar('csv');
});
// Recurring Events
document.getElementById('manageRecurringBtn').addEventListener('click', async () => {
await loadRecurringBlocks();
openModal('recurringModal');
});
document.getElementById('newRecurringBtn').addEventListener('click', () => {
// Redirect to recurring block creation page or open form
window.location.href = '/timer/recurring/new';
});
// Helper Functions
function showLoading(show) {
const loader = document.getElementById('calendarLoading');
if (show) {
loader.classList.add('show');
} else {
loader.classList.remove('show');
}
}
function setActiveView(view) {
currentView = view;
if (view === 'agenda') {
calendarView.style.display = 'none';
agendaView.classList.add('active');
document.getElementById('agendaBtn').classList.add('active');
document.getElementById('dayBtn').classList.remove('active');
document.getElementById('weekBtn').classList.remove('active');
document.getElementById('monthBtn').classList.remove('active');
} else {
calendarView.style.display = 'block';
agendaView.classList.remove('active');
document.getElementById('agendaBtn').classList.remove('active');
}
}
async function loadTasksForProject(projectId, prefix) {
const taskSelect = document.getElementById(prefix + 'Task');
taskSelect.innerHTML = '<option value="">{{ _("Loading...") }}</option>';
if (!projectId) {
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
taskSelect.disabled = true;
return;
}
try {
const res = await fetch(`/api/projects/${projectId}/tasks`);
const json = await res.json();
if (res.ok && json.tasks) {
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
json.tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = task.name;
taskSelect.appendChild(option);
});
taskSelect.disabled = false;
}
} catch(e) {
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
taskSelect.disabled = true;
}
}
async function createEvent() {
const data = {
project_id: parseInt(document.getElementById('createProject').value),
task_id: document.getElementById('createTask').value ? parseInt(document.getElementById('createTask').value) : null,
start_time: `${document.getElementById('createStartDate').value}T${document.getElementById('createStartTime').value}`,
end_time: `${document.getElementById('createEndDate').value}T${document.getElementById('createEndTime').value}`,
notes: document.getElementById('createNotes').value.trim() || null,
tags: document.getElementById('createTags').value.trim() || null,
billable: document.getElementById('createBillable').checked
};
try {
const res = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || 'Create failed');
}
showToast('{{ _("Entry created successfully") }}', 'success');
closeModal('eventCreateModal');
calendar.refetchEvents();
// Reset form
document.getElementById('eventCreateForm').reset();
} catch(e) {
showToast(e.message || '{{ _("Failed to create entry") }}', 'danger');
}
}
async function updateEventTime(event) {
try {
const res = await fetch(`/api/entry/${event.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start_time: event.start.toISOString(),
end_time: event.end.toISOString()
})
});
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || 'Update failed');
}
showToast('{{ _("Entry updated") }}', 'success');
} catch(e) {
showToast(e.message || '{{ _("Failed to update entry") }}', 'danger');
calendar.refetchEvents();
}
}
function showEventDetails(event) {
const props = event.extendedProps;
const content = document.getElementById('eventDetailContent');
const formatDate = (date) => {
return new Date(date).toLocaleString('{{ current_user.preferred_language or "en" }}', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
content.innerHTML = `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Project") }}</div>
<div class="event-detail-value">${props.project_name || '{{ _("N/A") }}'}</div>
</div>
${props.task_name ? `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Task") }}</div>
<div class="event-detail-value">${props.task_name}</div>
</div>
` : ''}
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Start") }}</div>
<div class="event-detail-value">${formatDate(event.start)}</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("End") }}</div>
<div class="event-detail-value">${formatDate(event.end)}</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Duration") }}</div>
<div class="event-detail-value">${(props.duration_hours || 0).toFixed(2)} {{ _("hours") }}</div>
</div>
${props.notes ? `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Notes") }}</div>
<div class="event-detail-value">${props.notes}</div>
</div>
` : ''}
${props.tags ? `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Tags") }}</div>
<div class="event-detail-value">${props.tags}</div>
</div>
` : ''}
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Billable") }}</div>
<div class="event-detail-value">
<span class="event-detail-badge ${props.billable ? 'billable' : 'non-billable'}">
${props.billable ? '{{ _("Yes") }}' : '{{ _("No") }}'}
</span>
</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Source") }}</div>
<div class="event-detail-value">${props.source === 'auto' ? '{{ _("Automatic Timer") }}' : '{{ _("Manual Entry") }}'}</div>
</div>
`;
// Set up edit and delete buttons
document.getElementById('editEventBtn').href = `/timer/edit/${event.id}`;
document.getElementById('deleteEventBtn').onclick = async () => {
if (confirm('{{ _("Are you sure you want to delete this entry?") }}')) {
await deleteEvent(event.id);
}
};
openModal('eventDetailModal');
}
async function deleteEvent(eventId) {
try {
const res = await fetch(`/api/entry/${eventId}`, { method: 'DELETE' });
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || 'Delete failed');
}
showToast('{{ _("Entry deleted") }}', 'success');
closeModal('eventDetailModal');
calendar.refetchEvents();
} catch(e) {
showToast(e.message || '{{ _("Failed to delete entry") }}', 'danger');
}
}
async function exportCalendar(format) {
const view = calendar.view;
const start = view.activeStart.toISOString();
const end = view.activeEnd.toISOString();
let url = `/api/calendar/export?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&format=${format}`;
if (currentFilters.project_id) {
url += `&project_id=${currentFilters.project_id}`;
}
window.location.href = url;
showToast('{{ _("Export started") }}', 'info');
}
async function loadRecurringBlocks() {
try {
const res = await fetch('/api/recurring-blocks');
const json = await res.json();
const listEl = document.getElementById('recurringList');
if (!json.blocks || json.blocks.length === 0) {
listEl.innerHTML = '<p class="text-muted text-center py-4">{{ _("No recurring blocks yet") }}</p>';
return;
}
listEl.innerHTML = json.blocks.map(block => `
<div class="recurring-item">
<div class="recurring-item-header">
<div class="recurring-item-title">${block.name}</div>
<span class="recurring-item-status ${block.is_active ? 'active' : 'inactive'}">
${block.is_active ? '{{ _("Active") }}' : '{{ _("Inactive") }}'}
</span>
</div>
<div class="recurring-item-details">
<i class="fas fa-project-diagram me-1"></i>${block.project_name || '{{ _("Unknown Project") }}'}
<span class="mx-2">•</span>
<i class="fas fa-redo me-1"></i>${block.recurrence}
${block.weekdays ? ` (${block.weekdays})` : ''}
<span class="mx-2">•</span>
<i class="fas fa-clock me-1"></i>${block.start_time_local} - ${block.end_time_local}
</div>
<div class="recurring-item-actions">
<button class="btn btn-sm btn-outline-primary" onclick="editRecurring(${block.id})">
<i class="fas fa-edit"></i> {{ _("Edit") }}
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteRecurring(${block.id})">
<i class="fas fa-trash"></i> {{ _("Delete") }}
</button>
</div>
</div>
`).join('');
} catch(e) {
showToast('{{ _("Failed to load recurring blocks") }}', 'danger');
}
}
async function renderAgendaView() {
showLoading(true);
try {
const view = calendar.view;
const start = view.activeStart.toISOString();
const end = view.activeEnd.toISOString();
let url = `/api/calendar/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
if (currentFilters.project_id) url += `&project_id=${currentFilters.project_id}`;
if (currentFilters.task_id) url += `&task_id=${currentFilters.task_id}`;
if (currentFilters.tags) url += `&tags=${encodeURIComponent(currentFilters.tags)}`;
const res = await fetch(url);
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Failed');
const events = json.events || [];
// Group events by date
const grouped = {};
events.forEach(event => {
const date = new Date(event.start).toLocaleDateString('{{ current_user.preferred_language or "en" }}', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
if (!grouped[date]) grouped[date] = [];
grouped[date].push(event);
});
const content = document.getElementById('agendaContent');
if (Object.keys(grouped).length === 0) {
content.innerHTML = '<p class="text-muted text-center py-4">{{ _("No events in this period") }}</p>';
return;
}
content.innerHTML = Object.entries(grouped).map(([date, dateEvents]) => `
<div class="agenda-date-group">
<div class="agenda-date-header">${date}</div>
${dateEvents.map(event => {
const start = new Date(event.start).toLocaleTimeString('{{ current_user.preferred_language or "en" }}', {
hour: '2-digit',
minute: '2-digit'
});
const end = new Date(event.end).toLocaleTimeString('{{ current_user.preferred_language or "en" }}', {
hour: '2-digit',
minute: '2-digit'
});
return `
<div class="agenda-event" style="border-left-color: ${event.backgroundColor};" onclick="showAgendaEvent(${event.id})">
<div class="agenda-event-time">${start} - ${end}</div>
<div class="agenda-event-details">
<div class="agenda-event-title">${event.title}</div>
<div class="agenda-event-meta">
${event.extendedProps.duration_hours ? `${event.extendedProps.duration_hours.toFixed(2)} hours` : ''}
${event.extendedProps.billable ? '<span class="badge bg-success ms-2">Billable</span>' : '<span class="badge bg-secondary ms-2">Non-billable</span>'}
</div>
</div>
</div>
`;
}).join('')}
</div>
`).join('');
} catch(e) {
showToast('{{ _("Failed to load agenda view") }}', 'danger');
} finally {
showLoading(false);
}
}
// Global functions for agenda view
window.showAgendaEvent = async function(eventId) {
try {
const res = await fetch(`/api/entry/${eventId}`);
const json = await res.json();
if (!res.ok) throw new Error('Failed to load event');
// Create a FullCalendar event-like object for consistency
const event = {
id: json.id,
start: json.start_time,
end: json.end_time,
extendedProps: {
project_name: json.project_name,
task_name: json.task_name,
notes: json.notes,
tags: json.tags,
billable: json.billable,
duration_hours: json.duration_hours,
source: json.source
}
};
showEventDetails(event);
} catch(e) {
showToast('{{ _("Failed to load event details") }}', 'danger');
}
};
// Global functions for recurring management
window.editRecurring = function(blockId) {
window.location.href = `/timer/recurring/edit/${blockId}`;
};
window.deleteRecurring = async function(blockId) {
if (!confirm('{{ _("Are you sure you want to delete this recurring block?") }}')) return;
try {
const res = await fetch(`/api/recurring-blocks/${blockId}`, { method: 'DELETE' });
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || 'Delete failed');
}
showToast('{{ _("Recurring block deleted") }}', 'success');
await loadRecurringBlocks();
} catch(e) {
showToast(e.message || '{{ _("Failed to delete recurring block") }}', 'danger');
}
};
});
// Modal helpers
function openModal(modalId) {
document.getElementById(modalId).classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
document.body.style.overflow = '';
}
// Toast notification
function showToast(message, type = 'info') {
// Use existing toast system if available
if (typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
// Fallback to alert
alert(message);
}
</script>
{% endblock %}