Feature: Quartalsansicht hinzugefügt
Neue Ansicht zeigt 3 Monate eines Quartals nebeneinander mit farbigen Event-Dots, Quartal-Navigation und Titelanzeige (z.B. Q2 2026). Klick auf Tag wechselt in Tagesansicht. Zweisprachig (DE/EN).
This commit is contained in:
@@ -616,6 +616,104 @@ a { color: var(--primary); text-decoration: none; }
|
||||
/* Day view specifics */
|
||||
.day-view .week-day-col { flex: 1; }
|
||||
|
||||
/* ── Quarter View ───────────────────────────────────────── */
|
||||
.quarter-view {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qtr-month {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qtr-month-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
.qtr-month-grid { padding: 6px 8px 8px; }
|
||||
.qtr-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.qtr-dow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.qtr-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, auto);
|
||||
}
|
||||
.qtr-cell {
|
||||
padding: 3px 2px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.qtr-cell:hover { background: var(--bg-hover); }
|
||||
.qtr-cell.today .qtr-day-num {
|
||||
background: var(--today-color, var(--primary));
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.qtr-cell.selected .qtr-day-num {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
opacity: .55;
|
||||
}
|
||||
.qtr-day-num {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
margin: 0 auto 2px;
|
||||
}
|
||||
.qtr-cell.other-month .qtr-day-num { color: var(--text-3); opacity: .45; }
|
||||
.qtr-cell.today .qtr-day-num { color: #fff; }
|
||||
.qtr-dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 6px;
|
||||
}
|
||||
.qtr-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qtr-dot.past { opacity: .45; }
|
||||
.qtr-dot-more {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
line-height: 6px;
|
||||
}
|
||||
|
||||
/* ── Agenda View ────────────────────────────────────────── */
|
||||
.agenda-view { padding: 16px; }
|
||||
.agenda-day { margin-bottom: 16px; }
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
<button class="view-btn" data-view="month" data-i18n="view_month">Monat</button>
|
||||
<button class="view-btn" data-view="week" data-i18n="view_week">Woche</button>
|
||||
<button class="view-btn" data-view="day" data-i18n="view_day">Tag</button>
|
||||
<button class="view-btn" data-view="quarter" data-i18n="view_quarter">Quartal</button>
|
||||
<button class="view-btn" data-view="agenda" data-i18n="view_agenda">Termine</button>
|
||||
</div>
|
||||
<button class="icon-btn" id="btn-settings" data-i18n-title="settings_title" title="Einstellungen">
|
||||
@@ -472,6 +473,7 @@
|
||||
<option value="month" data-i18n="view_month">Monat</option>
|
||||
<option value="week" data-i18n="view_week">Woche</option>
|
||||
<option value="day" data-i18n="view_day">Tag</option>
|
||||
<option value="quarter" data-i18n="view_quarter">Quartal</option>
|
||||
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, date
|
||||
import { renderMonth } from './views/month.js';
|
||||
import { renderWeek } from './views/week.js';
|
||||
import { renderAgenda } from './views/agenda.js';
|
||||
import { renderQuarter } from './views/quarter.js';
|
||||
import { openColorPicker } from './color-picker.js';
|
||||
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||
import { t, setLang, getLang } from './i18n.js';
|
||||
@@ -193,6 +194,10 @@ function getViewRange() {
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
} else if (state.currentView === 'quarter') {
|
||||
const q = Math.floor(d.getMonth() / 3);
|
||||
start = new Date(d.getFullYear(), q * 3, 1);
|
||||
end = new Date(d.getFullYear(), q * 3 + 3, 1);
|
||||
} else { // agenda
|
||||
start = new Date(d);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
@@ -232,6 +237,12 @@ function renderView() {
|
||||
weekStartDay,
|
||||
state.settings.hour_height || 60
|
||||
);
|
||||
} else if (state.currentView === 'quarter') {
|
||||
renderQuarter(container, state.currentDate, evs,
|
||||
date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
|
||||
showEventPopup,
|
||||
weekStartDay
|
||||
);
|
||||
} else {
|
||||
renderAgenda(container, state.currentDate, evs, showEventPopup);
|
||||
}
|
||||
@@ -273,6 +284,9 @@ function updateTitle() {
|
||||
: `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`;
|
||||
} else if (state.currentView === 'day') {
|
||||
title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
|
||||
} else if (state.currentView === 'quarter') {
|
||||
const q = Math.floor(d.getMonth() / 3) + 1;
|
||||
title = `Q${q} ${d.getFullYear()}`;
|
||||
} else {
|
||||
title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
@@ -610,6 +624,8 @@ function navigate(dir) {
|
||||
} else if (state.currentView === 'day') {
|
||||
state.currentDate = new Date(d);
|
||||
state.currentDate.setDate(d.getDate() + dir);
|
||||
} else if (state.currentView === 'quarter') {
|
||||
state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir * 3, 1);
|
||||
} else {
|
||||
state.currentDate = new Date(d);
|
||||
state.currentDate.setDate(d.getDate() + dir * 30);
|
||||
|
||||
@@ -24,7 +24,7 @@ const translations = {
|
||||
|
||||
// Topbar
|
||||
btn_today: 'Heute',
|
||||
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine',
|
||||
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_quarter: 'Quartal', view_agenda: 'Termine',
|
||||
btn_profile: 'Profil', btn_logout: 'Abmelden',
|
||||
|
||||
// Sidebar
|
||||
@@ -218,7 +218,7 @@ const translations = {
|
||||
|
||||
// Topbar
|
||||
btn_today: 'Today',
|
||||
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events',
|
||||
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_quarter: 'Quarter', view_agenda: 'Events',
|
||||
btn_profile: 'Profile', btn_logout: 'Log out',
|
||||
|
||||
// Sidebar
|
||||
|
||||
113
frontend/js/views/quarter.js
Normal file
113
frontend/js/views/quarter.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { isToday, isSameDay, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||
const year = currentDate.getFullYear();
|
||||
// Quarter: Q1=0, Q2=1, Q3=2, Q4=3
|
||||
const quarter = Math.floor(currentDate.getMonth() / 3);
|
||||
const firstMonthOfQ = quarter * 3;
|
||||
|
||||
// Build event map keyed by date string
|
||||
const evMap = {};
|
||||
events.forEach(ev => {
|
||||
const s = new Date(ev.start);
|
||||
const e = new Date(ev.end);
|
||||
const cur = new Date(s);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(e);
|
||||
endNorm.setHours(0, 0, 0, 0);
|
||||
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
|
||||
while (cur <= endNorm) {
|
||||
const key = dateKey(cur);
|
||||
if (!evMap[key]) evMap[key] = [];
|
||||
evMap[key].push(ev);
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
||||
const MONTHS = t('months');
|
||||
|
||||
const monthsHtml = [0, 1, 2].map(offset => {
|
||||
const month = firstMonthOfQ + offset;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Start grid on correct weekday
|
||||
const gridStart = new Date(firstDay);
|
||||
const startOffset = dayOfWeek(firstDay, weekStartDay);
|
||||
gridStart.setDate(gridStart.getDate() - startOffset);
|
||||
|
||||
const cells = [];
|
||||
const d = new Date(gridStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
cells.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
// DOW header
|
||||
const dowHeader = DOW.map(d => `<div class="qtr-dow">${d}</div>`).join('');
|
||||
|
||||
// Rows
|
||||
let rowsHtml = '';
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 7; col++) {
|
||||
const cell = cells[row * 7 + col];
|
||||
const key = dateKey(cell);
|
||||
const cellEvs = evMap[key] || [];
|
||||
|
||||
const isOther = cell.getMonth() !== month;
|
||||
const todayCls = isToday(cell) ? 'today' : '';
|
||||
const otherCls = isOther ? 'other-month' : '';
|
||||
const selCls = isSameDay(cell, currentDate) && !isToday(cell) ? 'selected' : '';
|
||||
|
||||
// Up to 3 event dots
|
||||
const dots = cellEvs.slice(0, 3).map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||
}).join('');
|
||||
const moreDot = cellEvs.length > 3
|
||||
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
|
||||
: '';
|
||||
|
||||
rowsHtml += `<div class="qtr-cell ${todayCls} ${otherCls} ${selCls}" data-date="${key}">
|
||||
<div class="qtr-day-num">${cell.getDate()}</div>
|
||||
<div class="qtr-dots">${dots}${moreDot}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="qtr-month">
|
||||
<div class="qtr-month-name">${MONTHS[month]}</div>
|
||||
<div class="qtr-month-grid">
|
||||
<div class="qtr-header">${dowHeader}</div>
|
||||
<div class="qtr-cells">${rowsHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<div class="quarter-view">${monthsHtml}</div>`;
|
||||
|
||||
// Click handlers
|
||||
container.querySelectorAll('.qtr-cell').forEach(cell => {
|
||||
cell.addEventListener('click', e => {
|
||||
// Check if a dot was clicked
|
||||
const dot = e.target.closest('.qtr-dot');
|
||||
if (dot) {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === dot.dataset.id && ev.url === dot.dataset.url);
|
||||
if (ev) { onEventClick(ev, dot); return; }
|
||||
}
|
||||
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
Reference in New Issue
Block a user