Files
TimeTracker/templates/timer/calendar.html
T
Dries Peeters 99e6584c04 feat(ux): add command palette, bulk edit, idle detect, calendar
- Command palette (Ctrl/Cmd+K) with quick nav (g d/p/r/t), start/stop
  timer, theme toggle
  - Adds modal to base layout and global shortcuts
  - Files: app/templates/base.html, app/static/commands.js

- Bulk edit for time entries with multi-select and quick actions
  - Delete, set billable/non-billable from dashboard Recent Entries
  - API: POST /api/entries/bulk
  - Files: app/templates/main/dashboard.html, app/routes/api.py

- Idle detection and “resume/stop at” support
  - Detect inactivity and prompt to stop at last active time
  - API: POST /api/timer/stop_at, POST /api/timer/resume
  - Files: app/static/idle.js, app/templates/base.html, app/routes/api.py

- Calendar (day/week/month) with drag‑to‑create entries
  - Route: /timer/calendar
  - APIs: GET /api/calendar/events, POST /api/entries
  - Files: app/routes/timer.py, templates/timer/calendar.html, app/routes/api.py

- UX polish: improved flash container spacing; reused existing
  skeleton loaders, loading spinners, and toasts

No breaking changes.
2025-10-06 13:09:36 +02:00

102 lines
4.2 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">
<style>
#calendar { min-height: 70vh; }
.fc-toolbar-title { font-weight: 600; }
.fc-event { cursor: pointer; }
.calendar-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.calendar-controls { display:flex; align-items:center; gap:.5rem; }
.calendar-assign { width: 280px; }
</style>
{% 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">
<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 }})</option>
{% endfor %}
</select>
<button class="btn btn-sm btn-outline-secondary" id="todayBtn">{{ _('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" id="weekBtn">{{ _('Week') }}</button>
<button class="btn btn-sm btn-outline-primary" id="monthBtn">{{ _('Month') }}</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
</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 calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
headerToolbar: false,
selectable: true,
selectMirror: true,
nowIndicator: true,
firstDay: 1,
slotDuration: '00:30:00',
events: async (info, success, failure) => {
try {
const url = `/api/calendar/events?start=${encodeURIComponent(info.startStr)}&end=${encodeURIComponent(info.endStr)}`;
const res = await fetch(url);
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Failed');
success(json.events || []);
} catch(e) { failure(e); }
},
select: async function(selection) {
const pid = projectSelect.value;
if (!pid) { showToast(`{{ _('Please select a project for new entries') }}`, 'warning'); return; }
try {
const res = await fetch('/api/entries', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ project_id: Number(pid), start_time: selection.startStr, end_time: selection.endStr }) });
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || 'Create failed');
showToast(`{{ _('Entry created') }}`, 'success');
calendar.refetchEvents();
} catch(e) {
showToast(`{{ _('Failed to create entry') }}`, 'danger');
}
},
eventClick: function(info){
const id = info.event.id;
window.location.href = `/timer/edit/${id}`;
}
});
calendar.render();
document.getElementById('todayBtn').addEventListener('click', () => calendar.today());
document.getElementById('dayBtn').addEventListener('click', () => calendar.changeView('timeGridDay'));
document.getElementById('weekBtn').addEventListener('click', () => calendar.changeView('timeGridWeek'));
document.getElementById('monthBtn').addEventListener('click', () => calendar.changeView('dayGridMonth'));
});
</script>
{% endblock %}