Branch data Line data Source code
1 : : #include "CalendarWidget.h"
2 : :
3 : : #include "ui/ThemeManager.h"
4 : :
5 : : #include <QCalendarWidget>
6 : : #include <QDateTime>
7 : : #include <QFontMetrics>
8 : : #include <QKeyEvent>
9 : : #include <QLocale>
10 : : #include <QMenu>
11 : : #include <QMouseEvent>
12 : : #include <QPainter>
13 : : #include <QSettings>
14 : : #include <QWheelEvent>
15 : :
16 : : #include "data/CalendarStore.h"
17 : : #include "ui/EventDetailPopup.h"
18 : :
19 : : #include <algorithm>
20 : :
21 : : // ═══════════════════════════════════════════════════════
22 : : // CalendarWidget implementation (Sprint 32 – T-336)
23 : : // ═══════════════════════════════════════════════════════
24 : :
25 : : static const int kToolbarH = 44;
26 : : static const int kHeaderH = 28;
27 : : static const int kTimeLabelW = 50; // T-424: dedicated time label column width
28 : : static const int kWeekHourStart = 0; // T-427: full 24h range
29 : : static const int kWeekHourEnd = 24;
30 : : static const int kHourH = 48; // T-427: fixed pixel height per hour
31 : : static const int kAllDayRowH = 20; // T-428: height of each all-day event row
32 : :
33 : : // Bug 3: Hash-based fallback color palette per calendar
34 : : // (shared palette, 67.B3: ThemeManager owns all color decisions)
35 : 125 : static QColor eventColor(const QString &color, const QString &calendarPath) {
36 [ + - ]: 125 : if (!color.isEmpty())
37 : 125 : return QColor(color);
38 [ # # ]: 0 : const QStringList palette = ThemeManager::calendarPalette();
39 : 0 : return QColor(palette.at(qHash(calendarPath) % palette.size()));
40 : 0 : }
41 : :
42 : 59 : static QMap<QString, QString> parseRRuleParts(const QString &rrule) {
43 : 59 : QMap<QString, QString> parts;
44 [ + - + - : 181 : for (const auto &part : rrule.split(QLatin1Char(';'), Qt::SkipEmptyParts)) {
+ - + + ]
45 : 122 : const int eq = part.indexOf(QLatin1Char('='));
46 [ - + ]: 122 : if (eq <= 0)
47 : 0 : continue;
48 [ + - + - : 122 : parts.insert(part.left(eq).trimmed().toUpper(),
+ - + - ]
49 [ + - + - ]: 244 : part.mid(eq + 1).trimmed());
50 : 59 : }
51 : 59 : return parts;
52 : 0 : }
53 : :
54 : 38 : static int weekdayFromRRuleToken(const QString &token) {
55 [ + - + - ]: 38 : const QString day = token.right(2).toUpper();
56 [ + + ]: 38 : if (day == QStringLiteral("MO")) return 1;
57 [ + + ]: 31 : if (day == QStringLiteral("TU")) return 2;
58 [ + + ]: 26 : if (day == QStringLiteral("WE")) return 3;
59 [ + + ]: 20 : if (day == QStringLiteral("TH")) return 4;
60 [ + + ]: 15 : if (day == QStringLiteral("FR")) return 5;
61 [ + + ]: 10 : if (day == QStringLiteral("SA")) return 6;
62 [ + - ]: 5 : if (day == QStringLiteral("SU")) return 7;
63 : 0 : return 0;
64 : 38 : }
65 : :
66 : 445 : static QDate displayEndDate(const CalendarEvent &event) {
67 [ + - + - ]: 445 : const QDate startDate = event.dtStart.toLocalTime().date();
68 [ + - - + ]: 445 : if (!event.dtEnd.isValid())
69 : 0 : return startDate;
70 [ + + ]: 445 : if (event.allDay) {
71 : : // iCal all-day DTEND is exclusive → last day is the day before. Clamp
72 : : // to the start date so legacy events stored with an INCLUSIVE end
73 : : // (pre-Sprint-65 EventEditDialog) still render instead of vanishing.
74 [ + - + - : 40 : return qMax(startDate, event.dtEnd.addDays(-1).toLocalTime().date());
+ - ]
75 : : }
76 [ + - + - : 405 : return event.dtEnd.addMSecs(-1).toLocalTime().date();
+ - ]
77 : : }
78 : :
79 : 445 : static void appendDisplayOccurrence(QList<CalendarEvent> *expanded,
80 : : const CalendarEvent &event,
81 : : const QDate &rangeStart,
82 : : const QDate &rangeEnd) {
83 [ + - - + ]: 445 : if (!event.dtStart.isValid())
84 : 0 : return;
85 : :
86 [ + - + - ]: 445 : const QDate startDate = event.dtStart.toLocalTime().date();
87 [ + - ]: 445 : const QDate endDate = displayEndDate(event);
88 : 445 : QDate day = qMax(startDate, rangeStart);
89 : 445 : const QDate lastDay = qMin(endDate, rangeEnd);
90 [ + - + - : 869 : while (day.isValid() && day <= lastDay) {
+ + + + ]
91 : 424 : CalendarEvent displayEvent = event;
92 [ + + ]: 424 : if (day != startDate) {
93 [ + - + - ]: 2 : displayEvent.dtStart = QDateTime(day, QTime(0, 0), Qt::LocalTime);
94 : : }
95 [ + + + - : 424 : if (day != endDate && event.dtEnd.isValid()) {
+ - + + ]
96 : : displayEvent.dtEnd =
97 [ + - + - : 2 : QDateTime(day.addDays(1), QTime(0, 0), Qt::LocalTime);
+ - ]
98 : : }
99 [ + + ]: 424 : if (startDate != endDate)
100 : 3 : displayEvent.allDay = true;
101 [ + - ]: 424 : expanded->append(displayEvent);
102 [ + - ]: 424 : day = day.addDays(1);
103 : 424 : }
104 : : }
105 : :
106 : 59 : static QDateTime parseRRuleUntil(const QString &value) {
107 [ + + ]: 59 : if (value.isEmpty())
108 : 49 : return {};
109 [ + + ]: 10 : if (value.length() == 8)
110 : 10 : return QDateTime(QDate::fromString(value, QStringLiteral("yyyyMMdd")),
111 [ + - + - : 15 : QTime(23, 59, 59), Qt::UTC);
+ - ]
112 : 5 : QString raw = value;
113 [ + - ]: 5 : const bool utc = raw.endsWith(QLatin1Char('Z'));
114 [ + - ]: 5 : if (utc)
115 [ + - ]: 5 : raw.chop(1);
116 : : QDateTime until =
117 [ + - ]: 5 : QDateTime::fromString(raw, QStringLiteral("yyyyMMddTHHmmss"));
118 [ + - + - : 5 : if (until.isValid() && utc)
+ - + - ]
119 [ + - ]: 5 : until.setTimeSpec(Qt::UTC);
120 : 5 : return until;
121 : 5 : }
122 : :
123 : 308 : static CalendarEvent shiftedOccurrence(const CalendarEvent &event,
124 : : const QDateTime &start) {
125 : 308 : CalendarEvent shifted = event;
126 : 308 : shifted.dtStart = start;
127 [ + - + - ]: 308 : if (event.dtEnd.isValid())
128 [ + - + - ]: 308 : shifted.dtEnd = start.addSecs(event.dtStart.secsTo(event.dtEnd));
129 : 308 : return shifted;
130 : 0 : }
131 : :
132 : 6 : static int skipDailyOccurrences(const CalendarEvent &event,
133 : : const QDate &rangeStart,
134 : : int interval) {
135 [ + - + - : 6 : const int days = event.dtStart.toLocalTime().date().daysTo(rangeStart);
+ - ]
136 [ + + ]: 6 : if (days <= interval)
137 : 5 : return 0;
138 : 1 : return qMax(0, days / interval - 1);
139 : : }
140 : :
141 : 44 : static int skipWeeklyOccurrences(const CalendarEvent &event,
142 : : const QDate &rangeStart,
143 : : int interval) {
144 [ + - + - : 44 : const int days = event.dtStart.toLocalTime().date().daysTo(rangeStart);
+ - ]
145 [ + + ]: 44 : if (days <= interval * 7)
146 : 41 : return 0;
147 : 3 : return qMax(0, days / (interval * 7) - 1);
148 : : }
149 : :
150 : 1 : static int skipMonthlyOccurrences(const CalendarEvent &event,
151 : : const QDate &rangeStart,
152 : : int interval) {
153 [ + - + - ]: 1 : const QDate start = event.dtStart.toLocalTime().date();
154 [ + - + - ]: 1 : int months = (rangeStart.year() - start.year()) * 12 +
155 [ + - + - ]: 1 : (rangeStart.month() - start.month());
156 [ + - + - : 1 : if (rangeStart.day() < start.day())
- + ]
157 : 0 : --months;
158 [ - + ]: 1 : if (months <= interval)
159 : 0 : return 0;
160 : 1 : return qMax(0, months / interval - 1);
161 : : }
162 : :
163 : 1 : static int skipYearlyOccurrences(const CalendarEvent &event,
164 : : const QDate &rangeStart,
165 : : int interval) {
166 [ + - + - ]: 1 : const QDate start = event.dtStart.toLocalTime().date();
167 [ + - + - ]: 1 : int years = rangeStart.year() - start.year();
168 [ + - + - : 1 : const QDate firstOfMonth(rangeStart.year(), start.month(), 1);
+ - ]
169 : : const QDate anniversary(
170 : : rangeStart.year(), start.month(),
171 [ + - + - : 1 : qMin(start.day(), firstOfMonth.daysInMonth()));
+ - + - +
- ]
172 [ - + ]: 1 : if (anniversary > rangeStart)
173 : 0 : --years;
174 [ - + ]: 1 : if (years <= interval)
175 : 0 : return 0;
176 : 1 : return qMax(0, years / interval - 1);
177 : : }
178 : :
179 : 90 : QList<CalendarEvent> CalendarWidget::expandEventsForDisplay(
180 : : const QList<CalendarEvent> &events,
181 : : const QDate &rangeStart,
182 : : const QDate &rangeEnd) {
183 : 90 : QList<CalendarEvent> expanded;
184 [ + - + - ]: 90 : const QDateTime rangeEndTime(rangeEnd, QTime(23, 59, 59), Qt::LocalTime);
185 : :
186 [ + + ]: 286 : for (const auto &event : events) {
187 [ + - + + ]: 196 : if (event.rrule.trimmed().isEmpty()) {
188 [ + - ]: 137 : appendDisplayOccurrence(&expanded, event, rangeStart, rangeEnd);
189 : 144 : continue;
190 : : }
191 : :
192 [ + - ]: 59 : const auto rule = parseRRuleParts(event.rrule);
193 [ + - + - ]: 118 : const QString freq = rule.value(QStringLiteral("FREQ")).toUpper();
194 : : const int interval =
195 [ + - ]: 177 : qMax(1, rule.value(QStringLiteral("INTERVAL"), QStringLiteral("1"))
196 [ + - ]: 59 : .toInt());
197 [ + - + - ]: 118 : const int count = rule.value(QStringLiteral("COUNT")).toInt();
198 [ + - + - ]: 118 : const QDateTime until = parseRRuleUntil(rule.value(QStringLiteral("UNTIL")));
199 : :
200 : 367 : auto shouldStop = [&](const QDateTime &candidate, int generated) {
201 [ + + + + ]: 367 : if (count > 0 && generated >= count)
202 : 8 : return true;
203 [ + + + + : 359 : if (until.isValid() && candidate > until)
+ + ]
204 : 3 : return true;
205 : 356 : return candidate > rangeEndTime;
206 : 59 : };
207 : :
208 : 59 : int generated = 0;
209 : 59 : int safety = 0;
210 [ + + + - : 228 : if (freq == QStringLiteral("WEEKLY") &&
+ + - - -
- ]
211 [ + - + + : 110 : rule.contains(QStringLiteral("BYDAY"))) {
+ + + + +
- - - -
- ]
212 : 7 : QList<int> weekdays;
213 : 7 : for (const auto &token :
214 [ + - + - : 66 : rule.value(QStringLiteral("BYDAY")).split(QLatin1Char(','))) {
+ - + - +
+ ]
215 [ + - + - ]: 38 : const int weekday = weekdayFromRRuleToken(token.trimmed());
216 [ + - ]: 38 : if (weekday > 0)
217 [ + - ]: 38 : weekdays.append(weekday);
218 : 7 : }
219 [ + - + - : 7 : std::sort(weekdays.begin(), weekdays.end());
+ - ]
220 [ - + ]: 7 : if (weekdays.isEmpty())
221 [ # # # # : 0 : weekdays.append(event.dtStart.date().dayOfWeek());
# # ]
222 : :
223 : : const QDate firstWeekStart =
224 [ + - + - : 7 : event.dtStart.date().addDays(-(event.dtStart.date().dayOfWeek() - 1));
+ - + - ]
225 : 7 : bool stopEvent = false;
226 [ + - ]: 19 : for (int week = 0; safety++ < 2000; week += interval) {
227 : 19 : bool passedRange = true;
228 [ + - + - : 87 : for (const int weekday : weekdays) {
+ + ]
229 : 75 : QDateTime candidate = event.dtStart;
230 [ + - + - ]: 75 : candidate.setDate(firstWeekStart.addDays(week * 7 + weekday - 1));
231 [ + - + + ]: 75 : if (candidate < event.dtStart)
232 : 30 : continue;
233 [ + - + + ]: 45 : if (shouldStop(candidate, generated)) {
234 : 7 : stopEvent = true;
235 : 7 : break;
236 : : }
237 [ + + + - : 38 : passedRange = passedRange && candidate > rangeEndTime;
- + ]
238 [ + - ]: 38 : appendDisplayOccurrence(
239 [ + - ]: 76 : &expanded, shiftedOccurrence(event, candidate),
240 : : rangeStart, rangeEnd);
241 : 38 : ++generated;
242 [ + + + ]: 75 : }
243 [ + + + - ]: 19 : if (stopEvent || passedRange)
244 : : break;
245 : : }
246 : 7 : continue;
247 : 7 : }
248 : :
249 : 52 : int skipped = 0;
250 [ + + ]: 52 : if (freq == QStringLiteral("DAILY"))
251 [ + - ]: 6 : skipped = skipDailyOccurrences(event, rangeStart, interval);
252 [ + + ]: 46 : else if (freq == QStringLiteral("WEEKLY"))
253 [ + - ]: 44 : skipped = skipWeeklyOccurrences(event, rangeStart, interval);
254 [ + + ]: 2 : else if (freq == QStringLiteral("MONTHLY"))
255 [ + - ]: 1 : skipped = skipMonthlyOccurrences(event, rangeStart, interval);
256 [ + - ]: 1 : else if (freq == QStringLiteral("YEARLY"))
257 [ + - ]: 1 : skipped = skipYearlyOccurrences(event, rangeStart, interval);
258 : :
259 : 52 : QDateTime candidate = event.dtStart;
260 [ + + ]: 52 : if (freq == QStringLiteral("DAILY"))
261 [ + - ]: 6 : candidate = candidate.addDays(skipped * interval);
262 [ + + ]: 46 : else if (freq == QStringLiteral("WEEKLY"))
263 [ + - ]: 44 : candidate = candidate.addDays(skipped * interval * 7);
264 [ + + ]: 2 : else if (freq == QStringLiteral("MONTHLY"))
265 [ + - ]: 1 : candidate = candidate.addMonths(skipped * interval);
266 [ + - ]: 1 : else if (freq == QStringLiteral("YEARLY"))
267 [ + - ]: 1 : candidate = candidate.addYears(skipped * interval);
268 : 52 : generated = skipped;
269 : :
270 : 52 : for (;
271 [ + - + + : 322 : !shouldStop(candidate, generated) && safety++ < 2000;) {
+ - + + ]
272 [ + - + - ]: 270 : appendDisplayOccurrence(&expanded, shiftedOccurrence(event, candidate),
273 : : rangeStart, rangeEnd);
274 : 270 : ++generated;
275 : :
276 [ + + ]: 270 : if (freq == QStringLiteral("DAILY"))
277 [ + - ]: 21 : candidate = candidate.addDays(interval);
278 [ + + ]: 249 : else if (freq == QStringLiteral("WEEKLY"))
279 [ + - ]: 231 : candidate = candidate.addDays(interval * 7);
280 [ + + ]: 18 : else if (freq == QStringLiteral("MONTHLY"))
281 [ + - ]: 15 : candidate = candidate.addMonths(interval);
282 [ + - ]: 3 : else if (freq == QStringLiteral("YEARLY"))
283 [ + - ]: 3 : candidate = candidate.addYears(interval);
284 : : else
285 : 0 : break;
286 : : }
287 [ + + + + : 73 : }
+ + ]
288 : :
289 [ + - + - : 90 : std::sort(expanded.begin(), expanded.end(),
+ - ]
290 : 1567 : [](const CalendarEvent &a, const CalendarEvent &b) {
291 : 1567 : return a.dtStart < b.dtStart;
292 : : });
293 : 90 : return expanded;
294 : 90 : }
295 : :
296 [ + - + - : 51 : CalendarWidget::CalendarWidget(QWidget *parent) : QWidget(parent) {
+ - ]
297 [ + - ]: 51 : m_currentDate = QDate::currentDate();
298 [ + - + - : 51 : m_displayMonth = QDate(m_currentDate.year(), m_currentDate.month(), 1);
+ - ]
299 [ + - ]: 51 : setFocusPolicy(Qt::StrongFocus);
300 [ + - ]: 51 : setMouseTracking(true);
301 [ + - ]: 51 : setMinimumSize(400, 300);
302 : :
303 : : // Restore persisted view mode + scroll position
304 [ + - ]: 51 : QSettings settings;
305 [ + - + - ]: 102 : int saved = settings.value(QStringLiteral("calendar/viewMode"), 0).toInt();
306 [ + + ]: 100 : m_viewMode = (saved == 1) ? WeekView
307 [ + + ]: 49 : : (saved == 2) ? DayView
308 : : : MonthView;
309 : : // T-541: Restore scroll offset (setViewMode guard skips this when mode matches)
310 [ + + + + ]: 51 : if (m_viewMode == WeekView || m_viewMode == DayView) {
311 [ + - ]: 15 : m_weekScrollOffset = settings.value(
312 [ + - ]: 10 : QStringLiteral("calendar/scrollOffset"), 7 * kHourH).toInt();
313 : : }
314 : :
315 : : // T-71.5a: Restore the per-calendar visibility selection. It is persisted
316 : : // in showCalendarFilterMenu (:1574-1578) but was never read back, so the
317 : : // selection reset to "all visible" on every restart. Empty list ⟹ all
318 : : // visible (the documented default semantics are preserved).
319 [ + - ]: 102 : const QStringList vis = settings.value(
320 [ + - ]: 153 : QStringLiteral("calendar/visibleCalendars")).toStringList();
321 [ + - ]: 51 : m_visibleCalendars = QSet<QString>(vis.begin(), vis.end());
322 : 51 : }
323 : :
324 : : // Sprint 76 (T-76.B4): resolve the locale from the app's manual language
325 : : // setting (i18n/language) instead of QLocale::system(), so day/month names
326 : : // follow the user's chosen UI language. "auto"/empty → system locale.
327 : 215 : QLocale CalendarWidget::appLocale() const {
328 [ + - ]: 215 : QSettings s;
329 : : const QString lang =
330 [ + - ]: 645 : s.value(QStringLiteral("i18n/language"), QStringLiteral("auto"))
331 [ + - ]: 215 : .toString();
332 [ + - + + : 215 : if (lang.isEmpty() || lang == QLatin1String("auto"))
+ + ]
333 [ + - ]: 211 : return QLocale::system();
334 [ + - ]: 4 : const QLocale l(lang);
335 [ + - - + : 4 : return l.name().isEmpty() ? QLocale::system() : l;
- - ]
336 : 215 : }
337 : :
338 : : // Sprint 76 (T-76.B4): repaint on language change — chrome labels (mode/today
339 : : // buttons, weekday headers, date titles) are tr()/locale-resolved at paint.
340 : 125 : void CalendarWidget::changeEvent(QEvent *event) {
341 : 125 : QWidget::changeEvent(event);
342 [ + + ]: 125 : if (event->type() == QEvent::LanguageChange)
343 : 4 : update();
344 : 125 : }
345 : :
346 : 26 : void CalendarWidget::setCalendarStore(CalendarStore *store) {
347 : 26 : m_store = store;
348 : 26 : loadEventsForVisibleRange();
349 : 26 : update();
350 : 26 : }
351 : :
352 : 8 : void CalendarWidget::setVisibleCalendars(const QSet<QString> &paths) {
353 : 8 : m_visibleCalendars = paths;
354 : 8 : loadEventsForVisibleRange();
355 : 8 : update();
356 : 8 : }
357 : :
358 : 57 : void CalendarWidget::setViewMode(ViewMode mode) {
359 [ + + ]: 57 : if (m_viewMode != mode) {
360 : 44 : m_viewMode = mode;
361 [ + - ]: 44 : QSettings settings;
362 : : // T-427: restore scroll position from settings (default: 7:00)
363 [ + + + + ]: 44 : if (mode == WeekView || mode == DayView) {
364 [ + - ]: 84 : m_weekScrollOffset = settings.value(
365 [ + - ]: 56 : QStringLiteral("calendar/scrollOffset"), 7 * kHourH).toInt();
366 : : }
367 [ + - ]: 88 : settings.setValue(QStringLiteral("calendar/viewMode"),
368 : : static_cast<int>(mode));
369 [ + - ]: 44 : loadEventsForVisibleRange();
370 [ + - ]: 44 : update();
371 : 44 : }
372 : 57 : }
373 : :
374 : 32 : void CalendarWidget::navigateToDate(const QDate &date) {
375 : 32 : m_currentDate = date;
376 [ + - + - : 32 : m_displayMonth = QDate(date.year(), date.month(), 1);
+ - ]
377 : 32 : loadEventsForVisibleRange();
378 : 32 : update();
379 : 32 : }
380 : :
381 : 92 : QDate CalendarWidget::firstVisibleDate() const {
382 : : // Monday of the week containing the 1st of the display month
383 : 92 : QDate first = m_displayMonth;
384 [ + - ]: 92 : int dow = first.dayOfWeek(); // 1=Mon
385 [ + - ]: 184 : return first.addDays(-(dow - 1));
386 : : }
387 : :
388 : 7 : QDate CalendarWidget::dateForCell(int row, int col) const {
389 [ + - + - ]: 7 : return firstVisibleDate().addDays(row * 7 + col);
390 : : }
391 : :
392 : 1 : QRect CalendarWidget::cellRectForDate(const QDate &date) const {
393 [ + - ]: 1 : QDate first = firstVisibleDate();
394 [ + - ]: 1 : int dayOffset = first.daysTo(date);
395 [ + - - + ]: 1 : if (dayOffset < 0 || dayOffset >= 42)
396 : 0 : return {};
397 : 1 : int row = dayOffset / 7;
398 : 1 : int col = dayOffset % 7;
399 : :
400 : 1 : int topY = kToolbarH + kHeaderH;
401 : 1 : int availH = height() - topY;
402 : 1 : int cellW = width() / 7;
403 : 1 : int cellH = availH / 6;
404 : :
405 : 1 : return QRect(col * cellW, topY + row * cellH, cellW, cellH);
406 : : }
407 : :
408 : : // ═══════════════════════════════════════════════════════
409 : : // Event loading
410 : : // ═══════════════════════════════════════════════════════
411 : :
412 : 124 : void CalendarWidget::loadEventsForVisibleRange() {
413 [ + - ]: 124 : m_eventsByDate.clear();
414 [ + - ]: 124 : m_visibleEvents.clear();
415 [ + + + - : 124 : if (!m_store || !m_store->isOpen())
+ + + + ]
416 : 39 : return;
417 : :
418 : 85 : QDate rangeStart, rangeEnd;
419 [ + + ]: 85 : if (m_viewMode == MonthView) {
420 : : // Load 3 months for prefetch
421 [ + - ]: 46 : rangeStart = m_displayMonth.addMonths(-1);
422 [ + - + - ]: 46 : rangeEnd = m_displayMonth.addMonths(2).addDays(-1);
423 [ + + ]: 39 : } else if (m_viewMode == YearView) {
424 : : // T-425: Load entire year
425 [ + - + - ]: 8 : rangeStart = QDate(m_displayMonth.year(), 1, 1);
426 [ + - + - ]: 8 : rangeEnd = QDate(m_displayMonth.year(), 12, 31);
427 [ + + ]: 31 : } else if (m_viewMode == DayView) {
428 : : // T-425: Single day
429 : 19 : rangeStart = m_currentDate;
430 : 19 : rangeEnd = m_currentDate;
431 : : } else {
432 : : // Week view: current week ± 1 week
433 [ + - ]: 12 : int dow = m_currentDate.dayOfWeek();
434 [ + - ]: 12 : rangeStart = m_currentDate.addDays(-(dow - 1) - 7);
435 [ + - ]: 12 : rangeEnd = m_currentDate.addDays(7 - dow + 7);
436 : : }
437 : :
438 : 85 : const QList<CalendarEvent> storedEvents = m_store->eventsForDateRange(
439 [ + - + - ]: 170 : QDateTime(rangeStart, QTime(0, 0), Qt::UTC),
440 [ + - + - : 255 : QDateTime(rangeEnd, QTime(23, 59, 59), Qt::UTC));
+ - ]
441 : : m_visibleEvents =
442 [ + - ]: 85 : expandEventsForDisplay(storedEvents, rangeStart, rangeEnd);
443 : :
444 [ + - + - : 468 : for (const auto &ev : m_visibleEvents) {
+ + ]
445 : : // Bug 3: Filter by visible calendars
446 [ + + + + ]: 421 : if (!m_visibleCalendars.isEmpty() &&
447 [ + + ]: 38 : !m_visibleCalendars.contains(ev.calendarPath))
448 : 20 : continue;
449 [ + - + - ]: 363 : QDate d = ev.dtStart.toLocalTime().date();
450 [ + - + - ]: 363 : m_eventsByDate[d].append(ev);
451 : : }
452 : 85 : }
453 : :
454 : : // ═══════════════════════════════════════════════════════
455 : : // Navigation
456 : : // ═══════════════════════════════════════════════════════
457 : :
458 : 19 : void CalendarWidget::moveSelection(int dayDelta) {
459 : 19 : m_currentDate = m_currentDate.addDays(dayDelta);
460 : : // Auto-switch month if needed
461 [ + - - + ]: 38 : if (m_currentDate.month() != m_displayMonth.month() ||
462 [ - + ]: 19 : m_currentDate.year() != m_displayMonth.year()) {
463 : 0 : m_displayMonth =
464 [ # # # # : 0 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
# # ]
465 : 0 : loadEventsForVisibleRange();
466 : : }
467 : 19 : update();
468 : 19 : }
469 : :
470 : 11 : void CalendarWidget::switchMonth(int delta) {
471 [ + + ]: 11 : if (m_viewMode == MonthView) {
472 : 7 : m_displayMonth = m_displayMonth.addMonths(delta);
473 : 0 : m_currentDate = QDate(m_displayMonth.year(), m_displayMonth.month(),
474 : 7 : qMin(m_currentDate.day(),
475 [ + - + - : 14 : m_displayMonth.daysInMonth()));
+ - + - +
- ]
476 [ + + ]: 4 : } else if (m_viewMode == DayView) {
477 : : // T-425: ±1 day
478 : 2 : m_currentDate = m_currentDate.addDays(delta);
479 : 2 : m_displayMonth =
480 [ + - + - : 2 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
+ - ]
481 [ + - ]: 2 : } else if (m_viewMode == YearView) {
482 : : // T-425: ±1 year
483 [ + - + - ]: 2 : m_displayMonth = QDate(m_displayMonth.year() + delta, 1, 1);
484 : 0 : m_currentDate = QDate(m_displayMonth.year(),
485 : 2 : qMin(m_currentDate.month(), 12),
486 [ + - + - : 4 : qMin(m_currentDate.day(), 28));
+ - + - ]
487 : : } else {
488 : 0 : m_currentDate = m_currentDate.addDays(delta * 7);
489 : 0 : m_displayMonth =
490 [ # # # # : 0 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
# # ]
491 : : }
492 : 11 : loadEventsForVisibleRange();
493 : 11 : update();
494 : 11 : }
495 : :
496 : : // ═══════════════════════════════════════════════════════
497 : : // Painting
498 : : // ═══════════════════════════════════════════════════════
499 : :
500 : 116 : void CalendarWidget::paintEvent(QPaintEvent *) {
501 [ + - ]: 116 : QPainter p(this);
502 [ + - ]: 116 : p.setRenderHint(QPainter::Antialiasing);
503 : :
504 : : // Background
505 [ + - + - : 116 : p.fillRect(rect(), palette().window());
+ - ]
506 : :
507 [ + - ]: 116 : paintToolbar(p);
508 : :
509 [ + + + + : 116 : switch (m_viewMode) {
- ]
510 [ + - ]: 77 : case MonthView: paintMonthView(p); break;
511 [ + - ]: 14 : case WeekView: paintWeekView(p); break;
512 [ + - ]: 17 : case DayView: paintDayView(p); break;
513 [ + - ]: 8 : case YearView: paintYearView(p); break;
514 : : }
515 : 116 : }
516 : :
517 : 116 : void CalendarWidget::paintToolbar(QPainter &p) {
518 : 116 : QRect toolbar(0, 0, width(), kToolbarH);
519 [ + - + - : 116 : p.fillRect(toolbar, palette().window().color().darker(105));
+ - ]
520 : :
521 [ + - ]: 116 : QFont titleFont = font();
522 [ + - ]: 116 : titleFont.setPointSize(14);
523 [ + - ]: 116 : titleFont.setBold(true);
524 [ + - ]: 116 : p.setFont(titleFont);
525 [ + - + - : 116 : p.setPen(palette().text().color());
+ - ]
526 : :
527 : : // Month/Year title
528 [ + - ]: 116 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
529 : 116 : QString title;
530 [ + + ]: 116 : if (m_viewMode == DayView) {
531 [ + - ]: 17 : title = loc.toString(m_currentDate, QStringLiteral("dddd, d. MMMM yyyy"));
532 [ + + ]: 99 : } else if (m_viewMode == YearView) {
533 [ + - + - ]: 8 : title = QString::number(m_displayMonth.year());
534 : : } else {
535 [ + - + - ]: 182 : title = loc.standaloneMonthName(m_displayMonth.month()) +
536 [ + - + - : 364 : QStringLiteral(" ") + QString::number(m_displayMonth.year());
+ - + - ]
537 : : }
538 [ + - ]: 116 : p.drawText(toolbar, Qt::AlignCenter, title);
539 : :
540 : : // Nav arrows
541 [ + - ]: 116 : QFont arrowFont = font();
542 [ + - ]: 116 : arrowFont.setPointSize(16);
543 [ + - ]: 116 : p.setFont(arrowFont);
544 [ + - ]: 116 : p.drawText(QRect(8, 0, 40, kToolbarH), Qt::AlignCenter,
545 : 232 : QStringLiteral("◀"));
546 [ + - ]: 116 : p.drawText(QRect(width() - 48, 0, 40, kToolbarH), Qt::AlignCenter,
547 : 232 : QStringLiteral("▶"));
548 : :
549 : : // T-425: Mode indicators (4 modes)
550 [ + - ]: 116 : QFont modeFont = font();
551 [ + - ]: 116 : modeFont.setPointSize(9);
552 [ + - ]: 116 : p.setFont(modeFont);
553 [ + - + - ]: 116 : QColor activeColor = palette().highlight().color();
554 [ + - + - ]: 116 : QColor inactiveColor = palette().text().color().darker(150);
555 : :
556 : 116 : int modeX = width() - 290;
557 : : struct ModeBtn { ViewMode mode; QString label; int w; };
558 : : ModeBtn modes[] = {
559 : : {YearView, tr("Year"), 40},
560 : : {MonthView, tr("Month"), 50},
561 : : {WeekView, tr("Week"), 50},
562 : : {DayView, tr("Day"), 35},
563 [ + - + - : 696 : };
+ - + - -
- - - - -
- - - - ]
564 : 116 : int x = modeX;
565 [ + + ]: 580 : for (const auto &mb : modes) {
566 [ + + + - ]: 464 : p.setPen(m_viewMode == mb.mode ? activeColor : inactiveColor);
567 [ + - ]: 464 : p.drawText(QRect(x, 0, mb.w, kToolbarH), Qt::AlignCenter, mb.label);
568 : 464 : x += mb.w + 5;
569 : : }
570 : : // Today button
571 [ + - + - : 116 : p.setPen(palette().link().color());
+ - ]
572 [ + - ]: 116 : p.drawText(QRect(x + 5, 0, 50, kToolbarH), Qt::AlignCenter,
573 [ + - ]: 232 : tr("Today"));
574 : :
575 : : // Filter button (☰) — highlighted when a filter is active
576 [ + - ]: 116 : QFont filterFont = font();
577 [ + - ]: 116 : filterFont.setPointSize(14);
578 [ + - ]: 116 : p.setFont(filterFont);
579 [ + + + - ]: 232 : p.setPen(m_visibleCalendars.isEmpty()
580 [ + - + - ]: 107 : ? palette().text().color()
581 [ + - + - ]: 9 : : palette().highlight().color());
582 [ + - ]: 116 : p.drawText(QRect(50, 0, 30, kToolbarH), Qt::AlignCenter,
583 : 232 : QStringLiteral("☰"));
584 [ + + - - ]: 812 : }
585 : :
586 : 77 : void CalendarWidget::paintMonthView(QPainter &p) {
587 : 77 : int cellW = width() / 7;
588 : 77 : int topY = kToolbarH;
589 : :
590 : : // Day-of-week headers
591 [ + - ]: 77 : QFont headerFont = font();
592 [ + - ]: 77 : headerFont.setPointSize(9);
593 [ + - ]: 77 : headerFont.setBold(true);
594 [ + - ]: 77 : p.setFont(headerFont);
595 [ + - + - : 77 : p.setPen(palette().text().color().darker(130));
+ - ]
596 : :
597 [ + - ]: 77 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
598 [ + + ]: 616 : for (int col = 0; col < 7; ++col) {
599 [ + - ]: 539 : QString dayName = loc.standaloneDayName(col + 1, QLocale::ShortFormat);
600 [ + - ]: 539 : p.drawText(QRect(col * cellW, topY, cellW, kHeaderH), Qt::AlignCenter,
601 : : dayName);
602 : 539 : }
603 : :
604 : 77 : topY += kHeaderH;
605 : 77 : int availH = height() - topY;
606 : 77 : int cellH = availH / 6;
607 [ + - ]: 77 : QDate today = QDate::currentDate();
608 [ + - ]: 77 : QDate firstVisible = firstVisibleDate();
609 : :
610 [ + - ]: 77 : QFont dayFont = font();
611 [ + - ]: 77 : dayFont.setPointSize(10);
612 [ + - ]: 77 : QFont eventFont = font();
613 [ + - ]: 77 : eventFont.setPointSize(8);
614 : :
615 [ + + ]: 539 : for (int row = 0; row < 6; ++row) {
616 [ + + ]: 3696 : for (int col = 0; col < 7; ++col) {
617 [ + - ]: 3234 : QDate cellDate = firstVisible.addDays(row * 7 + col);
618 : 3234 : QRect cellRect(col * cellW, topY + row * cellH, cellW, cellH);
619 : :
620 : : // Cell background
621 [ + - + - ]: 3234 : bool isCurrentMonth = (cellDate.month() == m_displayMonth.month());
622 : 3234 : bool isSelected = (cellDate == m_currentDate);
623 : 3234 : bool isToday = (cellDate == today);
624 : 3234 : bool isWeekend = (col >= 5);
625 : :
626 [ + - + - ]: 3234 : QColor bg = palette().base().color();
627 [ + + ]: 3234 : if (!isCurrentMonth)
628 : 910 : bg = bg.darker(108);
629 [ + + + + ]: 3234 : if (isWeekend && isCurrentMonth)
630 : 634 : bg = bg.darker(103);
631 : :
632 [ + - ]: 3234 : p.fillRect(cellRect, bg);
633 : :
634 : : // Selection highlight
635 [ + + ]: 3234 : if (isSelected) {
636 [ + - + - : 77 : p.setPen(QPen(palette().highlight().color(), 2));
+ - + - +
- ]
637 [ + - ]: 77 : p.drawRect(cellRect.adjusted(1, 1, -1, -1));
638 : : }
639 : :
640 : : // Grid lines
641 [ + - + - : 3234 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
642 [ + - ]: 3234 : p.drawRect(cellRect);
643 : :
644 : : // Day number
645 [ + - ]: 3234 : p.setFont(dayFont);
646 [ + + + - : 3234 : QColor dayColor = isCurrentMonth ? palette().text().color()
+ - ]
647 [ + - + - ]: 910 : : palette().placeholderText().color();
648 [ + - ]: 3234 : p.setPen(dayColor);
649 : :
650 [ + + ]: 3234 : if (isToday) {
651 : : // Circle around today's number
652 : 60 : int circleR = 14;
653 : 120 : QRect numRect(cellRect.x() + 4, cellRect.y() + 2, circleR * 2,
654 : 60 : circleR * 2);
655 [ + - + - : 60 : p.setBrush(palette().highlight().color());
+ - + - ]
656 [ + - ]: 60 : p.setPen(Qt::NoPen);
657 [ + - ]: 60 : p.drawEllipse(numRect);
658 [ + - + - : 60 : p.setPen(palette().highlightedText().color());
+ - ]
659 [ + - ]: 60 : p.drawText(numRect, Qt::AlignCenter,
660 [ + - + - ]: 120 : QString::number(cellDate.day()));
661 [ + - ]: 60 : p.setBrush(Qt::NoBrush);
662 : : } else {
663 [ + - ]: 6348 : p.drawText(QRect(cellRect.x() + 4, cellRect.y() + 2, cellW - 8,
664 : 3174 : 20),
665 : 3174 : Qt::AlignLeft | Qt::AlignTop,
666 [ + - + - ]: 6348 : QString::number(cellDate.day()));
667 : : }
668 : :
669 : : // Events for this day
670 [ + - ]: 3234 : auto it = m_eventsByDate.constFind(cellDate);
671 [ + - + + ]: 3234 : if (it != m_eventsByDate.constEnd()) {
672 [ + - ]: 73 : p.setFont(eventFont);
673 : 73 : int evY = cellRect.y() + 22;
674 : 73 : int maxEvents = (cellH - 24) / 14;
675 : 73 : int shown = 0;
676 [ + + ]: 149 : for (const auto &ev : it.value()) {
677 [ - + ]: 76 : if (shown >= maxEvents)
678 : 0 : break;
679 : : // Color dot
680 [ + - ]: 76 : QColor evColor = eventColor(ev.color, ev.calendarPath);
681 [ + - + - ]: 76 : p.setBrush(evColor);
682 [ + - ]: 76 : p.setPen(Qt::NoPen);
683 [ + - ]: 76 : p.drawEllipse(cellRect.x() + 4, evY + 3, 6, 6);
684 [ + - ]: 76 : p.setBrush(Qt::NoBrush);
685 : :
686 : : // Event text
687 [ + - + - : 76 : p.setPen(palette().text().color());
+ - ]
688 : 76 : QString text = ev.summary;
689 [ + - ]: 76 : QFontMetrics fm(eventFont);
690 [ + - ]: 76 : text = fm.elidedText(text, Qt::ElideRight, cellW - 18);
691 [ + - ]: 76 : p.drawText(QRect(cellRect.x() + 14, evY, cellW - 18, 14),
692 : 76 : Qt::AlignLeft | Qt::AlignVCenter, text);
693 : 76 : evY += 14;
694 : 76 : ++shown;
695 : 76 : }
696 [ - + ]: 73 : if (it.value().size() > maxEvents) {
697 [ # # # # : 0 : p.setPen(palette().placeholderText().color());
# # ]
698 [ # # ]: 0 : p.drawText(QRect(cellRect.x() + 4, evY, cellW - 8, 14),
699 : : Qt::AlignLeft,
700 : 0 : QStringLiteral("+%1 mehr")
701 [ # # ]: 0 : .arg(it.value().size() - maxEvents));
702 : : }
703 : : }
704 : : }
705 : : }
706 : 77 : }
707 : :
708 : 14 : void CalendarWidget::paintWeekView(QPainter &p) {
709 [ + - ]: 14 : int dow = m_currentDate.dayOfWeek(); // 1=Mon
710 [ + - ]: 14 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
711 : :
712 : : // T-424: Offset columns by time label width
713 : 14 : int cellW = (width() - kTimeLabelW) / 7;
714 : 14 : int topY = kToolbarH + kHeaderH;
715 : 14 : int hours = kWeekHourEnd - kWeekHourStart;
716 : :
717 [ + - ]: 14 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
718 [ + - ]: 14 : QDate today = QDate::currentDate();
719 : :
720 : : // T-428: Count all-day events to size the banner area
721 : 14 : int maxAllDay = 0;
722 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
723 [ + - ]: 98 : QDate d = weekStart.addDays(col);
724 [ + - ]: 98 : auto it = m_eventsByDate.constFind(d);
725 [ + - + + ]: 98 : if (it == m_eventsByDate.constEnd())
726 : 82 : continue;
727 : 16 : int count = 0;
728 [ + + ]: 33 : for (const auto &ev : it.value())
729 [ + + ]: 17 : if (ev.allDay) ++count;
730 : 16 : maxAllDay = qMax(maxAllDay, count);
731 : : }
732 [ + + ]: 14 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
733 : 14 : int gridTopY = topY + allDayH; // where the hour grid starts
734 : :
735 : : // Day headers
736 [ + - ]: 14 : QFont headerFont = font();
737 [ + - ]: 14 : headerFont.setPointSize(9);
738 [ + - ]: 14 : headerFont.setBold(true);
739 [ + - ]: 14 : p.setFont(headerFont);
740 [ + - + - : 14 : p.setPen(palette().text().color());
+ - ]
741 : :
742 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
743 [ + - ]: 98 : QDate d = weekStart.addDays(col);
744 [ + - ]: 196 : QString label = loc.standaloneDayName(col + 1, QLocale::ShortFormat) +
745 [ + - + - : 392 : QStringLiteral(" ") + QString::number(d.day());
+ - + - ]
746 [ + - + - ]: 98 : QColor bg = palette().base().color();
747 [ + + ]: 98 : if (d == today)
748 [ + - + - ]: 6 : bg = palette().highlight().color().lighter(180);
749 [ + + ]: 98 : if (d == m_currentDate)
750 [ + - + - ]: 14 : bg = palette().highlight().color().lighter(160);
751 [ + - ]: 98 : p.fillRect(QRect(kTimeLabelW + col * cellW, kToolbarH, cellW, kHeaderH), bg);
752 [ + + + - : 190 : p.setPen(d == today ? palette().highlight().color()
+ - + - ]
753 [ + - + - ]: 92 : : palette().text().color());
754 [ + - ]: 98 : p.drawText(QRect(kTimeLabelW + col * cellW, kToolbarH, cellW, kHeaderH),
755 : : Qt::AlignCenter, label);
756 : 98 : }
757 : :
758 : : // T-428: All-day event banners (between headers and hour grid)
759 [ + + ]: 14 : if (allDayH > 0) {
760 [ + - ]: 4 : p.fillRect(QRect(kTimeLabelW, topY, width() - kTimeLabelW, allDayH),
761 [ + - + - ]: 4 : palette().alternateBase().color());
762 [ + - ]: 4 : QFont adFont = font();
763 [ + - ]: 4 : adFont.setPointSize(8);
764 [ + - ]: 4 : p.setFont(adFont);
765 [ + + ]: 32 : for (int col = 0; col < 7; ++col) {
766 [ + - ]: 28 : QDate d = weekStart.addDays(col);
767 [ + - ]: 28 : auto it = m_eventsByDate.constFind(d);
768 [ + - + + ]: 28 : if (it == m_eventsByDate.constEnd())
769 : 19 : continue;
770 : 9 : int row = 0;
771 [ + + ]: 18 : for (const auto &ev : it.value()) {
772 [ + + ]: 9 : if (!ev.allDay) continue;
773 [ + - ]: 4 : QColor evColor = eventColor(ev.color, ev.calendarPath);
774 : 4 : QRect r(kTimeLabelW + col * cellW + 2, topY + row * kAllDayRowH + 2,
775 : 4 : cellW - 4, kAllDayRowH - 3);
776 [ + - ]: 4 : p.fillRect(r, evColor.lighter(170));
777 [ + - ]: 4 : p.setPen(evColor);
778 [ + - ]: 4 : p.drawRect(r);
779 [ + - + - : 4 : p.setPen(palette().text().color());
+ - ]
780 [ + - ]: 4 : QFontMetrics fm(adFont);
781 [ + - ]: 4 : p.drawText(r.adjusted(3, 1, -2, -1), Qt::AlignLeft | Qt::AlignVCenter,
782 [ + - ]: 8 : fm.elidedText(ev.summary, Qt::ElideRight, r.width() - 6));
783 : 4 : ++row;
784 : 4 : }
785 : : }
786 : : // separator line
787 [ + - + - : 4 : p.setPen(QPen(palette().mid().color(), 1));
+ - + - +
- ]
788 [ + - ]: 4 : p.drawLine(kTimeLabelW, gridTopY - 1, width(), gridTopY - 1);
789 : 4 : }
790 : :
791 : : // T-427: Scroll offset — clamp to valid range
792 : 14 : int totalGridH = hours * kHourH;
793 : 14 : int viewH = height() - gridTopY;
794 : 14 : int maxScroll = qMax(0, totalGridH - viewH);
795 [ + - ]: 14 : m_weekScrollOffset = qBound(0, m_weekScrollOffset, maxScroll);
796 : :
797 : : // Clip to the grid area for scrollable content
798 [ + - ]: 14 : p.save();
799 [ + - ]: 14 : p.setClipRect(QRect(0, gridTopY, width(), viewH));
800 : :
801 : : // Hour grid (with scroll offset)
802 [ + - ]: 14 : QFont hourFont = font();
803 [ + - ]: 14 : hourFont.setPointSize(8);
804 [ + - ]: 14 : p.setFont(hourFont);
805 : :
806 [ + + ]: 350 : for (int h = 0; h < hours; ++h) {
807 : 336 : int y = gridTopY + h * kHourH - m_weekScrollOffset;
808 [ + + + + : 336 : if (y > height() || y + kHourH < gridTopY)
+ + ]
809 : 144 : continue; // off-screen
810 : :
811 [ + - + - : 192 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
812 [ + - ]: 192 : p.drawLine(kTimeLabelW, y, width(), y);
813 : :
814 : : // Hour label
815 [ + - + - : 192 : p.setPen(palette().placeholderText().color());
+ - ]
816 [ + - ]: 192 : p.drawText(QRect(2, y, kTimeLabelW - 4, kHourH), Qt::AlignTop | Qt::AlignRight,
817 [ + - ]: 576 : QStringLiteral("%1:00").arg(kWeekHourStart + h, 2, 10,
818 : 192 : QLatin1Char('0')));
819 : : }
820 : :
821 : : // Column dividers
822 [ + + ]: 126 : for (int col = 0; col <= 7; ++col) {
823 [ + - + - : 112 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
824 : 112 : p.drawLine(kTimeLabelW + col * cellW, gridTopY,
825 [ + - ]: 112 : kTimeLabelW + col * cellW, gridTopY + totalGridH - m_weekScrollOffset);
826 : : }
827 : :
828 : : // Timed events (with scroll offset)
829 [ + - ]: 14 : QFont eventFont = font();
830 [ + - ]: 14 : eventFont.setPointSize(8);
831 [ + - ]: 14 : p.setFont(eventFont);
832 : :
833 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
834 [ + - ]: 98 : QDate d = weekStart.addDays(col);
835 [ + - ]: 98 : auto it = m_eventsByDate.constFind(d);
836 [ + - + + ]: 98 : if (it == m_eventsByDate.constEnd())
837 : 82 : continue;
838 : :
839 [ + + ]: 33 : for (const auto &ev : it.value()) {
840 [ + + ]: 17 : if (ev.allDay)
841 : 4 : continue; // shown in all-day banner
842 : :
843 [ + - + - ]: 13 : QTime startTime = ev.dtStart.toLocalTime().time();
844 : : QTime endTime =
845 [ + - + - : 13 : ev.dtEnd.isValid() ? ev.dtEnd.toLocalTime().time() : startTime.addSecs(3600);
+ - + - -
- + - -
- ]
846 : :
847 : : int startMinute =
848 [ + - + - ]: 13 : (startTime.hour() - kWeekHourStart) * 60 + startTime.minute();
849 : : int endMinute =
850 [ + - + - ]: 13 : (endTime.hour() - kWeekHourStart) * 60 + endTime.minute();
851 : :
852 [ - + ]: 13 : if (startMinute < 0)
853 : 0 : startMinute = 0;
854 [ - + ]: 13 : if (endMinute <= startMinute)
855 : 0 : endMinute = startMinute + 30;
856 : :
857 : 13 : int y1 = gridTopY + (startMinute * kHourH) / 60 - m_weekScrollOffset;
858 : 13 : int y2 = gridTopY + (endMinute * kHourH) / 60 - m_weekScrollOffset;
859 : 13 : int evH = qMax(y2 - y1, 16);
860 : :
861 : : // Skip events fully off-screen
862 [ + - - + : 13 : if (y1 + evH < gridTopY || y1 > height())
- + ]
863 : 0 : continue;
864 : :
865 [ + - ]: 13 : QColor evColor = eventColor(ev.color, ev.calendarPath);
866 : 13 : QRect evRect(kTimeLabelW + col * cellW + 2, y1, cellW - 4, evH);
867 : :
868 : : // Event block
869 [ + - ]: 13 : p.fillRect(evRect, evColor.lighter(160));
870 [ + - + - : 13 : p.setPen(QPen(evColor, 2));
+ - ]
871 [ + - ]: 13 : p.drawLine(evRect.left(), evRect.top(), evRect.left(),
872 : : evRect.bottom());
873 : :
874 : : // Event text
875 [ + - + - : 13 : p.setPen(palette().text().color());
+ - ]
876 [ + - ]: 13 : QFontMetrics fm(eventFont);
877 : 13 : QString text = fm.elidedText(ev.summary, Qt::ElideRight,
878 [ + - ]: 13 : evRect.width() - 6);
879 [ + - ]: 13 : p.drawText(evRect.adjusted(4, 2, -2, -2),
880 : 13 : Qt::AlignLeft | Qt::AlignTop, text);
881 : 13 : }
882 : : }
883 : :
884 : : // Sprint 39: Drag preview rectangle
885 [ + + + - : 14 : if (m_isDragging && m_dragStartTime.isValid() && m_dragEndTime.isValid()) {
+ - + - +
- + + ]
886 [ + - ]: 1 : QDateTime t0 = qMin(m_dragStartTime, m_dragEndTime);
887 [ + - ]: 1 : QDateTime t1 = qMax(m_dragStartTime, m_dragEndTime);
888 : : // Compute column from drag start date
889 [ + - + - ]: 1 : int dragCol = weekStart.daysTo(t0.date());
890 [ + - + - ]: 1 : if (dragCol >= 0 && dragCol < 7) {
891 [ + - + - : 1 : double startMinutes = t0.time().hour() * 60.0 + t0.time().minute()
+ - + - ]
892 : 1 : - kWeekHourStart * 60.0;
893 [ + - + - : 1 : double endMinutes = t1.time().hour() * 60.0 + t1.time().minute()
+ - + - ]
894 : 1 : - kWeekHourStart * 60.0;
895 : 1 : int y0 = gridTopY + qRound(startMinutes * kHourH / 60.0) - m_weekScrollOffset;
896 : 1 : int y1 = gridTopY + qRound(endMinutes * kHourH / 60.0) - m_weekScrollOffset;
897 : 1 : int x0 = kTimeLabelW + dragCol * cellW + 2;
898 : 1 : QRect dragRect(x0, y0, cellW - 4, y1 - y0);
899 : : QColor dragAccent(
900 [ + - + - ]: 2 : ThemeManager::instance().color(QStringLiteral("@accent")));
901 : 1 : QColor dragFill = dragAccent;
902 [ + - ]: 1 : dragFill.setAlpha(50);
903 [ + - ]: 1 : p.fillRect(dragRect, dragFill);
904 [ + - + - : 1 : p.setPen(QPen(dragAccent, 2));
+ - ]
905 [ + - ]: 1 : p.drawRoundedRect(dragRect, 4, 4);
906 : : // Time label
907 [ + - ]: 1 : QFont tf = font();
908 [ + - ]: 1 : tf.setPointSize(8);
909 [ + - ]: 1 : tf.setBold(true);
910 [ + - ]: 1 : p.setFont(tf);
911 [ + - ]: 1 : p.setPen(dragAccent);
912 [ + - + - ]: 3 : QString timeStr = t0.time().toString(QStringLiteral("HH:mm")) +
913 [ + - ]: 3 : QStringLiteral(" – ") +
914 [ + - + - : 4 : t1.time().toString(QStringLiteral("HH:mm"));
+ - ]
915 [ + - ]: 1 : p.drawText(dragRect.adjusted(4, 2, -2, -2), Qt::AlignLeft | Qt::AlignTop,
916 : : timeStr);
917 : 1 : }
918 : 1 : }
919 : :
920 [ + - ]: 14 : p.restore(); // remove clip
921 : 14 : }
922 : :
923 : : // ═══════════════════════════════════════════════════════
924 : : // T-425: Day View
925 : : // ═══════════════════════════════════════════════════════
926 : :
927 : 17 : void CalendarWidget::paintDayView(QPainter &p) {
928 : 17 : int topY = kToolbarH;
929 : 17 : int hours = kWeekHourEnd - kWeekHourStart;
930 : :
931 : : // All-day events banner
932 [ + - ]: 17 : auto it = m_eventsByDate.constFind(m_currentDate);
933 : 17 : int allDayCount = 0;
934 [ + - + + ]: 17 : if (it != m_eventsByDate.constEnd()) {
935 [ + + ]: 23 : for (const auto &ev : it.value())
936 [ + + ]: 13 : if (ev.allDay) ++allDayCount;
937 : : }
938 [ + + ]: 17 : int allDayH = allDayCount > 0 ? allDayCount * kAllDayRowH + 4 : 0;
939 : 17 : int gridTopY = topY + allDayH;
940 : :
941 : : // All-day banners
942 [ + + + - : 17 : if (allDayH > 0 && it != m_eventsByDate.constEnd()) {
+ - + + ]
943 [ + - ]: 3 : p.fillRect(QRect(kTimeLabelW, topY, width() - kTimeLabelW, allDayH),
944 [ + - + - ]: 3 : palette().alternateBase().color());
945 [ + - ]: 3 : QFont adFont = font();
946 [ + - ]: 3 : adFont.setPointSize(9);
947 [ + - ]: 3 : p.setFont(adFont);
948 : 3 : int row = 0;
949 [ + + ]: 6 : for (const auto &ev : it.value()) {
950 [ - + ]: 3 : if (!ev.allDay) continue;
951 [ + - ]: 3 : QColor evColor = eventColor(ev.color, ev.calendarPath);
952 : 3 : QRect r(kTimeLabelW + 2, topY + row * kAllDayRowH + 2,
953 : 3 : width() - kTimeLabelW - 4, kAllDayRowH - 3);
954 [ + - ]: 3 : p.fillRect(r, evColor.lighter(170));
955 [ + - ]: 3 : p.setPen(evColor);
956 [ + - ]: 3 : p.drawRect(r);
957 [ + - + - : 3 : p.setPen(palette().text().color());
+ - ]
958 [ + - ]: 3 : QFontMetrics fm(adFont);
959 [ + - ]: 3 : p.drawText(r.adjusted(6, 1, -2, -1), Qt::AlignLeft | Qt::AlignVCenter,
960 [ + - ]: 6 : fm.elidedText(ev.summary, Qt::ElideRight, r.width() - 12));
961 : 3 : ++row;
962 : 3 : }
963 [ + - + - : 3 : p.setPen(QPen(palette().mid().color(), 1));
+ - + - +
- ]
964 [ + - ]: 3 : p.drawLine(kTimeLabelW, gridTopY - 1, width(), gridTopY - 1);
965 : 3 : }
966 : :
967 : : // Scroll bounds
968 : 17 : int totalGridH = hours * kHourH;
969 : 17 : int viewH = height() - gridTopY;
970 : 17 : int maxScroll = qMax(0, totalGridH - viewH);
971 [ + - ]: 17 : m_weekScrollOffset = qBound(0, m_weekScrollOffset, maxScroll);
972 : :
973 [ + - ]: 17 : p.save();
974 [ + - ]: 17 : p.setClipRect(QRect(0, gridTopY, width(), viewH));
975 : :
976 : : // Hour grid
977 [ + - ]: 17 : QFont hourFont = font();
978 [ + - ]: 17 : hourFont.setPointSize(9);
979 [ + - ]: 17 : p.setFont(hourFont);
980 : :
981 [ + + ]: 425 : for (int h = 0; h < hours; ++h) {
982 : 408 : int y = gridTopY + h * kHourH - m_weekScrollOffset;
983 [ + + + + : 408 : if (y > height() || y + kHourH < gridTopY) continue;
+ + ]
984 : :
985 [ + - + - : 241 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
986 [ + - ]: 241 : p.drawLine(kTimeLabelW, y, width(), y);
987 [ + - + - : 241 : p.setPen(palette().placeholderText().color());
+ - ]
988 [ + - ]: 241 : p.drawText(QRect(2, y, kTimeLabelW - 4, kHourH),
989 : 241 : Qt::AlignTop | Qt::AlignRight,
990 [ + - ]: 723 : QStringLiteral("%1:00").arg(kWeekHourStart + h, 2, 10,
991 : 241 : QLatin1Char('0')));
992 : : }
993 : :
994 : : // Timed events
995 [ + - + + ]: 17 : if (it != m_eventsByDate.constEnd()) {
996 [ + - ]: 10 : QFont eventFont = font();
997 [ + - ]: 10 : eventFont.setPointSize(9);
998 [ + - ]: 10 : p.setFont(eventFont);
999 : :
1000 [ + + ]: 23 : for (const auto &ev : it.value()) {
1001 [ + + ]: 13 : if (ev.allDay) continue;
1002 : :
1003 [ + - + - ]: 10 : QTime startTime = ev.dtStart.toLocalTime().time();
1004 [ + - ]: 10 : QTime endTime = ev.dtEnd.isValid()
1005 [ + - + - : 20 : ? ev.dtEnd.toLocalTime().time()
- - ]
1006 [ + - + - : 20 : : startTime.addSecs(3600);
- - ]
1007 : :
1008 [ + - + - ]: 10 : int startMin = (startTime.hour() - kWeekHourStart) * 60 + startTime.minute();
1009 [ + - + - ]: 10 : int endMin = (endTime.hour() - kWeekHourStart) * 60 + endTime.minute();
1010 [ - + ]: 10 : if (startMin < 0) startMin = 0;
1011 [ - + ]: 10 : if (endMin <= startMin) endMin = startMin + 30;
1012 : :
1013 : 10 : int y1 = gridTopY + (startMin * kHourH) / 60 - m_weekScrollOffset;
1014 : 10 : int y2 = gridTopY + (endMin * kHourH) / 60 - m_weekScrollOffset;
1015 : 10 : int evH = qMax(y2 - y1, 20);
1016 [ + - - + : 10 : if (y1 + evH < gridTopY || y1 > height()) continue;
- + ]
1017 : :
1018 [ + - ]: 10 : QColor evColor = eventColor(ev.color, ev.calendarPath);
1019 : 10 : QRect evRect(kTimeLabelW + 4, y1, width() - kTimeLabelW - 8, evH);
1020 [ + - ]: 10 : p.fillRect(evRect, evColor.lighter(160));
1021 [ + - + - : 10 : p.setPen(QPen(evColor, 2));
+ - ]
1022 [ + - ]: 10 : p.drawLine(evRect.left(), evRect.top(), evRect.left(), evRect.bottom());
1023 : :
1024 [ + - + - : 10 : p.setPen(palette().text().color());
+ - ]
1025 [ + - ]: 10 : QFontMetrics fm(eventFont);
1026 [ + - ]: 10 : QString time = startTime.toString(QStringLiteral("HH:mm"));
1027 [ + - + - ]: 20 : QString text = time + QStringLiteral(" ") + ev.summary;
1028 [ + - ]: 10 : p.drawText(evRect.adjusted(6, 2, -4, -2),
1029 : 10 : Qt::AlignLeft | Qt::AlignTop,
1030 [ + - ]: 20 : fm.elidedText(text, Qt::ElideRight, evRect.width() - 10));
1031 : 10 : }
1032 : 10 : }
1033 : :
1034 [ + - ]: 17 : p.restore();
1035 : 17 : }
1036 : :
1037 : : // ═══════════════════════════════════════════════════════
1038 : : // T-425: Year View — 4×3 mini-calendar grid
1039 : : // ═══════════════════════════════════════════════════════
1040 : :
1041 : 8 : void CalendarWidget::paintYearView(QPainter &p) {
1042 : 8 : int topY = kToolbarH + 8;
1043 : 8 : int cols = 4;
1044 : 8 : int rows = 3;
1045 : 8 : int cellW = (width() - 20) / cols;
1046 : 8 : int cellH = (height() - topY - 10) / rows;
1047 : :
1048 [ + - ]: 8 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
1049 [ + - ]: 8 : QDate today = QDate::currentDate();
1050 [ + - ]: 8 : int year = m_displayMonth.year();
1051 : :
1052 [ + - ]: 8 : QFont monthFont = font();
1053 [ + - ]: 8 : monthFont.setPointSize(9);
1054 [ + - ]: 8 : monthFont.setBold(true);
1055 : :
1056 [ + - ]: 8 : QFont dayFont = font();
1057 [ + - ]: 8 : dayFont.setPointSize(7);
1058 : :
1059 [ + - ]: 8 : QFont kwFont = font();
1060 [ + - ]: 8 : kwFont.setPointSize(6);
1061 [ + - ]: 8 : kwFont.setItalic(true);
1062 : :
1063 : 8 : int kwColW = 18; // width for KW column
1064 : :
1065 [ + + ]: 104 : for (int mon = 1; mon <= 12; ++mon) {
1066 : 96 : int r = (mon - 1) / cols;
1067 : 96 : int c = (mon - 1) % cols;
1068 : 96 : int mx = 10 + c * cellW;
1069 : 96 : int my = topY + r * cellH;
1070 : :
1071 : : // Month name
1072 [ + - ]: 96 : p.setFont(monthFont);
1073 [ + - + - : 96 : p.setPen(palette().text().color());
+ - ]
1074 [ + - ]: 96 : p.drawText(QRect(mx, my, cellW, 16), Qt::AlignCenter,
1075 [ + - ]: 192 : loc.standaloneMonthName(mon, QLocale::ShortFormat));
1076 : :
1077 : : // Day-of-week header row (Mo Di Mi ...)
1078 [ + - ]: 96 : p.setFont(kwFont);
1079 [ + - + - : 96 : p.setPen(palette().placeholderText().color());
+ - ]
1080 : 96 : int dayW = (cellW - kwColW) / 7;
1081 : 96 : int dayH = 13;
1082 : 96 : int gridY = my + 18;
1083 : 96 : int gridX = mx + kwColW;
1084 : :
1085 [ + + ]: 768 : for (int dow = 0; dow < 7; ++dow) {
1086 [ + - ]: 672 : p.drawText(QRect(gridX + dow * dayW, gridY - dayH, dayW, dayH),
1087 : : Qt::AlignCenter,
1088 [ + - ]: 1344 : loc.standaloneDayName(dow + 1, QLocale::NarrowFormat));
1089 : : }
1090 : :
1091 : : // Mini-calendar grid
1092 [ + - ]: 96 : p.setFont(dayFont);
1093 [ + - ]: 96 : QDate first(year, mon, 1);
1094 [ + - ]: 96 : int startDow = first.dayOfWeek(); // 1=Mon
1095 [ + - ]: 96 : int daysInMonth = first.daysInMonth();
1096 : :
1097 : : // Track which weeks we've drawn KW for
1098 : 96 : int lastKwDrawn = -1;
1099 : :
1100 [ + + ]: 3016 : for (int d = 1; d <= daysInMonth; ++d) {
1101 : 2920 : int dayOfWeek = ((startDow - 1 + d - 1) % 7); // 0=Mon
1102 : 2920 : int week = (startDow - 1 + d - 1) / 7;
1103 : 2920 : int dx = gridX + dayOfWeek * dayW;
1104 : 2920 : int dy = gridY + week * dayH;
1105 : :
1106 [ + - ]: 2920 : QDate thisDate(year, mon, d);
1107 : :
1108 : : // KW column — draw once per week row
1109 [ + + ]: 2920 : if (week != lastKwDrawn) {
1110 : 504 : lastKwDrawn = week;
1111 [ + - ]: 504 : int kw = thisDate.weekNumber();
1112 [ + - ]: 504 : p.setFont(kwFont);
1113 [ + - + - : 504 : p.setPen(palette().placeholderText().color());
+ - ]
1114 [ + - ]: 504 : p.drawText(QRect(mx, dy, kwColW - 2, dayH),
1115 : 504 : Qt::AlignRight | Qt::AlignVCenter,
1116 [ + - ]: 1008 : QString::number(kw));
1117 [ + - ]: 504 : p.setFont(dayFont);
1118 : : }
1119 : :
1120 : : // Highlight today
1121 [ + + ]: 2920 : if (thisDate == today) {
1122 [ + - ]: 8 : p.fillRect(QRect(dx, dy, dayW, dayH),
1123 [ + - + - ]: 16 : palette().highlight().color().lighter(160));
1124 : : }
1125 : : // Selected date
1126 [ + + ]: 2920 : if (thisDate == m_currentDate) {
1127 [ + - + - : 8 : p.setPen(QPen(palette().highlight().color(), 1));
+ - + - +
- ]
1128 [ + - ]: 8 : p.drawRect(QRect(dx, dy, dayW - 1, dayH - 1));
1129 : : }
1130 : :
1131 : : // Day number
1132 [ + - ]: 2920 : auto it = m_eventsByDate.constFind(thisDate);
1133 [ + - + + : 2920 : bool hasEvents = (it != m_eventsByDate.constEnd() && !it.value().isEmpty());
+ - ]
1134 : :
1135 [ + + + - : 5827 : p.setPen(hasEvents ? palette().highlight().color()
+ - + - ]
1136 [ + - + - ]: 2907 : : palette().text().color());
1137 [ + - ]: 2920 : p.drawText(QRect(dx, dy, dayW, dayH - 3), Qt::AlignHCenter | Qt::AlignTop,
1138 [ + - ]: 5840 : QString::number(d));
1139 : :
1140 : : // Colored event dot below number
1141 [ + + ]: 2920 : if (hasEvents) {
1142 [ + - ]: 13 : QColor dotColor = eventColor(it.value().first().color,
1143 : 13 : it.value().first().calendarPath);
1144 [ + - ]: 13 : p.setPen(Qt::NoPen);
1145 [ + - + - ]: 13 : p.setBrush(dotColor);
1146 [ + - ]: 13 : p.drawEllipse(dx + dayW / 2 - 2, dy + dayH - 5, 4, 4);
1147 [ + - ]: 13 : p.setBrush(Qt::NoBrush);
1148 : : }
1149 : : }
1150 : : }
1151 : 8 : }
1152 : :
1153 : : // ═══════════════════════════════════════════════════════
1154 : : // Input handling
1155 : : // ═══════════════════════════════════════════════════════
1156 : :
1157 : 41 : void CalendarWidget::keyPressEvent(QKeyEvent *event) {
1158 [ + + + + : 41 : switch (event->key()) {
+ + + + +
+ + + +
+ ]
1159 : 5 : case Qt::Key_H:
1160 : : case Qt::Key_Left:
1161 : 5 : moveSelection(-1);
1162 : 5 : break;
1163 : 5 : case Qt::Key_L:
1164 : : case Qt::Key_Right:
1165 : 5 : moveSelection(1);
1166 : 5 : break;
1167 : 5 : case Qt::Key_J:
1168 : : case Qt::Key_Down:
1169 : 5 : moveSelection(7);
1170 : 5 : break;
1171 : 4 : case Qt::Key_K:
1172 : : case Qt::Key_Up:
1173 : 4 : moveSelection(-7);
1174 : 4 : break;
1175 : 1 : case Qt::Key_N:
1176 : 1 : switchMonth(1);
1177 : 1 : break;
1178 : 2 : case Qt::Key_P:
1179 : 2 : switchMonth(-1);
1180 : 2 : break;
1181 : 2 : case Qt::Key_T:
1182 [ + - + - ]: 2 : navigateToDate(QDate::currentDate());
1183 : 2 : break;
1184 : 1 : case Qt::Key_1:
1185 : 1 : setViewMode(MonthView);
1186 : 1 : break;
1187 : 1 : case Qt::Key_2:
1188 : 1 : setViewMode(WeekView);
1189 : 1 : break;
1190 : 1 : case Qt::Key_3:
1191 : 1 : setViewMode(DayView);
1192 : 1 : break;
1193 : 1 : case Qt::Key_4:
1194 : 1 : setViewMode(YearView);
1195 : 1 : break;
1196 : 2 : case Qt::Key_Return:
1197 : : case Qt::Key_Enter: {
1198 [ + - ]: 2 : auto it = m_eventsByDate.constFind(m_currentDate);
1199 [ + - + + : 2 : if (it != m_eventsByDate.constEnd() && !it.value().isEmpty()) {
+ - + + ]
1200 : 1 : const auto &ev = it.value().first();
1201 [ + - ]: 1 : emit eventClicked(ev);
1202 [ + - + - : 1 : showEventPopup(ev, mapToGlobal(cellRectForDate(m_currentDate).center()));
+ - ]
1203 : : }
1204 : 2 : break;
1205 : : }
1206 : 2 : case Qt::Key_Escape:
1207 : : // Delegate to MainWindow ESC handler (CommandBar check first)
1208 : 2 : event->ignore();
1209 : 2 : break;
1210 : 9 : default:
1211 : 9 : QWidget::keyPressEvent(event);
1212 : 9 : break;
1213 : : }
1214 : 41 : }
1215 : :
1216 : 20 : void CalendarWidget::mousePressEvent(QMouseEvent *event) {
1217 [ + - + + ]: 20 : if (qRound(event->position().y()) < kToolbarH) {
1218 : : // Toolbar clicks
1219 [ + - + + ]: 10 : if (qRound(event->position().x()) < 48) {
1220 : 4 : switchMonth(-1);
1221 [ + - - + ]: 6 : } else if (qRound(event->position().x()) > width() - 48) {
1222 : 0 : switchMonth(1);
1223 [ + - + + ]: 6 : } else if (qRound(event->position().x()) > width() - 290) {
1224 : : // T-425: 4 mode buttons + today
1225 [ + - ]: 5 : int relX = qRound(event->position().x()) - (width() - 290);
1226 [ + + ]: 5 : if (relX < 40)
1227 : 1 : setViewMode(YearView);
1228 [ + + ]: 4 : else if (relX < 95)
1229 : 1 : setViewMode(MonthView);
1230 [ + + ]: 3 : else if (relX < 150)
1231 : 1 : setViewMode(WeekView);
1232 [ + + ]: 2 : else if (relX < 190)
1233 : 1 : setViewMode(DayView);
1234 : : else
1235 [ + - + - ]: 1 : navigateToDate(QDate::currentDate());
1236 [ + - + - : 1 : } else if (qRound(event->position().x()) >= 50 && qRound(event->position().x()) < 80) {
+ - - + -
+ ]
1237 : : // Filter button (☰) → open calendar filter menu
1238 [ # # # # ]: 0 : showCalendarFilterMenu(mapToGlobal(QPoint(50, kToolbarH)));
1239 [ + - + - : 1 : } else if (qRound(event->position().x()) >= 80 && qRound(event->position().x()) < width() - 300) {
+ - + - +
- ]
1240 : : // T-429: Click on title area → date picker popup
1241 [ + - - + : 1 : auto *picker = new QCalendarWidget(this);
- - ]
1242 [ + - ]: 1 : picker->setWindowFlags(Qt::Popup);
1243 : 1 : picker->setSelectedDate(m_currentDate);
1244 : 1 : picker->setGridVisible(true);
1245 : 1 : connect(picker, &QCalendarWidget::activated, this,
1246 [ + - ]: 1 : [this, picker](const QDate &date) {
1247 : 1 : navigateToDate(date);
1248 : 1 : picker->close();
1249 : 1 : picker->deleteLater();
1250 : 1 : });
1251 [ + - + - : 1 : picker->move(mapToGlobal(QPoint(qRound(event->position().x()) - 100, kToolbarH)));
+ - ]
1252 : 1 : picker->show();
1253 : : }
1254 : 10 : return;
1255 : : }
1256 : :
1257 : : // T-422: Calendar filter on right-click anywhere
1258 [ + + + + : 10 : if (event->button() == Qt::RightButton && m_store) {
+ + ]
1259 [ + - + - ]: 3 : showCalendarFilterMenu(event->globalPosition().toPoint());
1260 : 3 : return;
1261 : : }
1262 : :
1263 [ + + ]: 7 : if (m_viewMode == MonthView) {
1264 : 4 : int cellW = width() / 7;
1265 : 4 : int topY = kToolbarH + kHeaderH;
1266 : 4 : int availH = height() - topY;
1267 : 4 : int cellH = availH / 6;
1268 : :
1269 [ + - ]: 4 : int col = qRound(event->position().x()) / cellW;
1270 [ + - ]: 4 : int row = (qRound(event->position().y()) - topY) / cellH;
1271 [ + - + - : 4 : if (col >= 0 && col < 7 && row >= 0 && row < 6) {
+ - + - ]
1272 [ + - ]: 4 : QDate clicked = dateForCell(row, col);
1273 : 4 : m_currentDate = clicked;
1274 [ + - + - : 4 : if (clicked.month() != m_displayMonth.month()) {
- + ]
1275 [ # # # # : 0 : m_displayMonth = QDate(clicked.year(), clicked.month(), 1);
# # ]
1276 [ # # ]: 0 : loadEventsForVisibleRange();
1277 : : }
1278 : : // T-533: Single-click on event → popup, on empty area → just select
1279 [ + - + - ]: 4 : CalendarEvent ev = eventAtPosition(event->pos());
1280 [ - + ]: 4 : if (!ev.uid.isEmpty()) {
1281 [ # # ]: 0 : emit eventClicked(ev);
1282 [ # # # # ]: 0 : showEventPopup(ev, event->globalPosition().toPoint());
1283 : : }
1284 [ + - ]: 4 : emit dateClicked(clicked);
1285 [ + - ]: 4 : update();
1286 : 4 : }
1287 [ + + + - ]: 3 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1288 : : // T-533: Start drag-to-select for time range
1289 [ + - + - ]: 3 : CalendarEvent ev = eventAtPosition(event->pos());
1290 [ + + ]: 3 : if (!ev.uid.isEmpty()) {
1291 : : // Clicked on existing event → show popup
1292 [ + - ]: 1 : emit eventClicked(ev);
1293 [ + - + - ]: 1 : showEventPopup(ev, event->globalPosition().toPoint());
1294 : : } else {
1295 : : // Empty area → start drag
1296 : 2 : m_isDragging = true;
1297 [ + - ]: 2 : m_dragStartPos = event->pos();
1298 [ + - ]: 2 : m_dragCurrentPos = event->pos();
1299 [ + - + - ]: 2 : m_dragStartTime = timeAtPosition(event->pos());
1300 : 2 : m_dragEndTime = m_dragStartTime;
1301 [ + - + - ]: 2 : setCursor(Qt::CrossCursor);
1302 : : }
1303 : 3 : }
1304 : : }
1305 : :
1306 : 4 : void CalendarWidget::wheelEvent(QWheelEvent *event) {
1307 : : // T-427: In week/day view, scroll vertically through the 24h timeline
1308 [ + - - + ]: 4 : if (m_viewMode == WeekView || m_viewMode == DayView) {
1309 : 0 : m_weekScrollOffset -= event->angleDelta().y() / 3;
1310 : : // T-541: Persist scroll position
1311 [ # # # # ]: 0 : QSettings().setValue(QStringLiteral("calendar/scrollOffset"),
1312 : : m_weekScrollOffset);
1313 : 0 : update();
1314 : 0 : event->accept();
1315 : 0 : return;
1316 : : }
1317 : 4 : m_wheelAccumulator += event->angleDelta().y();
1318 [ + + ]: 6 : while (m_wheelAccumulator >= 120) {
1319 : 2 : switchMonth(-1);
1320 : 2 : m_wheelAccumulator -= 120;
1321 : : }
1322 [ + + ]: 6 : while (m_wheelAccumulator <= -120) {
1323 : 2 : switchMonth(1);
1324 : 2 : m_wheelAccumulator += 120;
1325 : : }
1326 : : }
1327 : :
1328 : 33 : void CalendarWidget::resizeEvent(QResizeEvent *event) {
1329 : 33 : QWidget::resizeEvent(event);
1330 : 33 : update();
1331 : 33 : }
1332 : :
1333 : 4 : void CalendarWidget::mouseDoubleClickEvent(QMouseEvent *event) {
1334 [ + - + - ]: 4 : CalendarEvent ev = eventAtPosition(event->pos());
1335 [ - + ]: 4 : if (!ev.uid.isEmpty()) {
1336 : : // T-534: Double-click on event → edit
1337 [ # # ]: 0 : emit editEventRequested(ev);
1338 [ + + ]: 4 : } else if (m_viewMode == MonthView) {
1339 : : // T-533: Double-click on empty day → create
1340 : 3 : int cellW = width() / 7;
1341 : 3 : int topY = kToolbarH + kHeaderH;
1342 : 3 : int availH = height() - topY;
1343 : 3 : int cellH = availH / 6;
1344 [ + - ]: 3 : int col = qRound(event->position().x()) / cellW;
1345 [ + - ]: 3 : int row = (qRound(event->position().y()) - topY) / cellH;
1346 [ + - + - : 3 : if (col >= 0 && col < 7 && row >= 0 && row < 6) {
+ - + - ]
1347 [ + - ]: 3 : QDate clicked = dateForCell(row, col);
1348 : : // Only create if click is in the date-header area (top 22px),
1349 : : // or if day has no events at all
1350 [ + - ]: 3 : auto it = m_eventsByDate.constFind(clicked);
1351 : 3 : int evY = topY + row * cellH + 22;
1352 [ + - ]: 3 : bool inEventArea = qRound(event->position().y()) >= evY;
1353 [ + - - + : 3 : bool hasEvents = (it != m_eventsByDate.constEnd() && !it.value().isEmpty());
- - ]
1354 [ + + + - ]: 3 : if (!inEventArea || !hasEvents)
1355 [ + - ]: 3 : emit createEventRequested(clicked);
1356 : : }
1357 [ - + - - ]: 1 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1358 : : // T-533: Double-click on empty time → create at that time
1359 [ + - + - ]: 1 : QDateTime clickTime = timeAtPosition(event->pos());
1360 [ + - + - ]: 1 : if (clickTime.isValid()) {
1361 [ + - + - ]: 1 : emit createEventRequested(clickTime.date(),
1362 [ + - ]: 1 : clickTime.time(),
1363 [ + - + - ]: 2 : clickTime.time().addSecs(3600));
1364 : : }
1365 : 1 : }
1366 : 4 : }
1367 : :
1368 : : // T-533: Drag-to-select in Week/Day view
1369 : 2 : void CalendarWidget::mouseMoveEvent(QMouseEvent *event) {
1370 [ + - ]: 2 : if (m_isDragging) {
1371 : 2 : m_dragCurrentPos = event->pos();
1372 [ + - + - ]: 2 : m_dragEndTime = timeAtPosition(event->pos());
1373 : 2 : update();
1374 : : }
1375 : 2 : QWidget::mouseMoveEvent(event);
1376 : 2 : }
1377 : :
1378 : 6 : void CalendarWidget::mouseReleaseEvent(QMouseEvent *event) {
1379 [ + + ]: 6 : if (m_isDragging) {
1380 : 2 : m_isDragging = false;
1381 [ + - + - ]: 2 : setCursor(Qt::ArrowCursor);
1382 [ + - + - ]: 2 : m_dragEndTime = timeAtPosition(event->pos());
1383 : :
1384 : : // Ensure start < end
1385 [ + - ]: 2 : QDateTime start = qMin(m_dragStartTime, m_dragEndTime);
1386 [ + - ]: 2 : QDateTime end = qMax(m_dragStartTime, m_dragEndTime);
1387 : :
1388 : : // Only emit if dragged at least 15 minutes
1389 [ + - + - : 4 : if (start.isValid() && end.isValid() &&
+ - + - +
- ]
1390 [ + - + - ]: 2 : start.secsTo(end) >= 15 * 60) {
1391 [ + - + - : 2 : emit createEventRequested(start.date(), start.time(), end.time());
+ - + - ]
1392 : : }
1393 [ + - ]: 2 : update();
1394 : 2 : }
1395 : 6 : QWidget::mouseReleaseEvent(event);
1396 : 6 : }
1397 : :
1398 : : // T-533: Map pixel position to datetime in Week/Day view
1399 : 10 : QDateTime CalendarWidget::timeAtPosition(const QPoint &pos) const {
1400 [ + + - + ]: 10 : if (m_viewMode != WeekView && m_viewMode != DayView)
1401 : 0 : return {};
1402 : :
1403 : 10 : int timeColW = 50; // matches paintWeekView/paintDayView
1404 : 10 : int topY = kToolbarH + kHeaderH;
1405 : :
1406 : : // Sprint 39: Account for all-day banner height (same as paint methods)
1407 : 10 : int maxAllDay = 0;
1408 [ + + ]: 10 : if (m_viewMode == WeekView) {
1409 [ + - ]: 6 : int dow = m_currentDate.dayOfWeek();
1410 [ + - ]: 6 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
1411 [ + + ]: 48 : for (int c = 0; c < 7; ++c) {
1412 [ + - ]: 42 : QDate d = weekStart.addDays(c);
1413 [ + - ]: 42 : auto it = m_eventsByDate.constFind(d);
1414 [ + - + + ]: 42 : if (it != m_eventsByDate.constEnd()) {
1415 : 8 : int cnt = 0;
1416 [ + + ]: 20 : for (const auto &e : it.value())
1417 [ - + ]: 12 : if (e.allDay) ++cnt;
1418 : 8 : maxAllDay = qMax(maxAllDay, cnt);
1419 : : }
1420 : : }
1421 : : } else { // DayView
1422 [ + - ]: 4 : auto it = m_eventsByDate.constFind(m_currentDate);
1423 [ + - - + ]: 4 : if (it != m_eventsByDate.constEnd()) {
1424 [ # # ]: 0 : for (const auto &e : it.value())
1425 [ # # ]: 0 : if (e.allDay) ++maxAllDay;
1426 : : }
1427 : : }
1428 [ - + ]: 10 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
1429 : 10 : int gridTopY = topY + allDayH;
1430 : :
1431 : 10 : int totalH = (kWeekHourEnd - kWeekHourStart) * kHourH;
1432 : :
1433 : : // Y → hour calculation (accounting for scroll offset and all-day banner)
1434 : 10 : int relY = pos.y() - gridTopY + m_weekScrollOffset;
1435 [ - + ]: 10 : if (relY < 0) relY = 0;
1436 [ - + ]: 10 : if (relY > totalH) relY = totalH;
1437 : 10 : double hours = kWeekHourStart + (double)relY / kHourH;
1438 [ + - ]: 10 : int h = qBound(0, (int)hours, 23);
1439 [ + - ]: 10 : int m = qBound(0, (int)((hours - h) * 60), 59);
1440 : : // Snap to 15-minute intervals
1441 : 10 : m = (m / 15) * 15;
1442 : :
1443 : : // X → date calculation
1444 : 10 : QDate date;
1445 [ + + ]: 10 : if (m_viewMode == DayView) {
1446 : 4 : date = m_currentDate;
1447 : : } else {
1448 : : // WeekView: 7 columns after time column
1449 : 6 : int dayW = (width() - timeColW) / 7;
1450 : 6 : int dayCol = (pos.x() - timeColW) / dayW;
1451 [ + - ]: 6 : dayCol = qBound(0, dayCol, 6);
1452 : : // Week starts on Monday
1453 [ + - ]: 6 : int dow = m_currentDate.dayOfWeek(); // 1=Mon
1454 [ + - ]: 6 : date = m_currentDate.addDays(-dow + 1 + dayCol);
1455 : : }
1456 : :
1457 [ + - + - ]: 10 : return QDateTime(date, QTime(h, m));
1458 : : }
1459 : :
1460 : 2 : void CalendarWidget::showEventPopup(const CalendarEvent &event,
1461 : : const QPoint &pos) {
1462 [ + - ]: 2 : if (!m_popup) {
1463 [ + - - + : 2 : m_popup = new EventDetailPopup(this);
- - ]
1464 : 2 : connect(m_popup, &EventDetailPopup::editRequested, this,
1465 [ + - ]: 2 : &CalendarWidget::editEventRequested);
1466 : 2 : connect(m_popup, &EventDetailPopup::deleteRequested, this,
1467 [ + - ]: 4 : &CalendarWidget::deleteEventRequested);
1468 : : }
1469 : 2 : m_popup->showEvent(event, pos);
1470 : 2 : }
1471 : :
1472 : 11 : CalendarEvent CalendarWidget::eventAtPosition(const QPoint &pos) const {
1473 [ + + ]: 11 : if (m_viewMode == MonthView) {
1474 : 7 : int cellW = width() / 7;
1475 : 7 : int topY = kToolbarH + kHeaderH;
1476 : 7 : int availH = height() - topY;
1477 : 7 : int cellH = availH / 6;
1478 : :
1479 : 7 : int col = pos.x() / cellW;
1480 : 7 : int row = (pos.y() - topY) / cellH;
1481 [ + - + - : 7 : if (col < 0 || col >= 7 || row < 0 || row >= 6)
+ - - + ]
1482 : 7 : return {};
1483 : :
1484 [ + - + - ]: 7 : QDate cellDate = firstVisibleDate().addDays(row * 7 + col);
1485 [ + - ]: 7 : auto it = m_eventsByDate.constFind(cellDate);
1486 [ + - + - ]: 7 : if (it == m_eventsByDate.constEnd())
1487 : 7 : return {};
1488 : :
1489 : : // Event slots start at 22px from top of cell, each 16px tall
1490 : 0 : int evY = topY + row * cellH + 22;
1491 : 0 : int eventSlotH = 16;
1492 : 0 : int maxEvents = (cellH - 24) / eventSlotH;
1493 : 0 : int clickY = pos.y() - evY;
1494 [ # # ]: 0 : if (clickY < 0)
1495 : 0 : return {};
1496 : 0 : int evIdx = clickY / eventSlotH;
1497 [ # # # # : 0 : if (evIdx >= 0 && evIdx < qMin(it.value().size(), maxEvents))
# # ]
1498 : 0 : return it.value().at(evIdx);
1499 : :
1500 [ + + + - ]: 4 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1501 : : // T-533: Hit-test events in Week/Day view
1502 : : // First check all-day banner area
1503 [ + - ]: 4 : int dow = m_currentDate.dayOfWeek();
1504 [ + - ]: 4 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
1505 [ + + ]: 6 : int cellW = (m_viewMode == WeekView) ? (width() - kTimeLabelW) / 7
1506 : 2 : : width() - kTimeLabelW;
1507 : 4 : int topY = kToolbarH + kHeaderH;
1508 : :
1509 : : // Count max all-day events for banner height
1510 : 4 : int maxAllDay = 0;
1511 [ + + ]: 4 : int numCols = (m_viewMode == WeekView) ? 7 : 1;
1512 [ + + ]: 20 : for (int c = 0; c < numCols; ++c) {
1513 [ + + + - ]: 16 : QDate d = (m_viewMode == WeekView) ? weekStart.addDays(c) : m_currentDate;
1514 [ + - ]: 16 : auto it2 = m_eventsByDate.constFind(d);
1515 [ + - + + ]: 16 : if (it2 != m_eventsByDate.constEnd()) {
1516 : 4 : int cnt = 0;
1517 [ + + ]: 10 : for (const auto &e : it2.value())
1518 [ + + ]: 6 : if (e.allDay) ++cnt;
1519 : 4 : maxAllDay = qMax(maxAllDay, cnt);
1520 : : }
1521 : : }
1522 [ + + ]: 4 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
1523 : :
1524 : : // Check if click is in all-day banner
1525 [ + - + + : 4 : if (pos.y() >= topY && pos.y() < topY + allDayH) {
+ + ]
1526 : 1 : int col = (pos.x() - kTimeLabelW) / cellW;
1527 [ + - + - ]: 1 : if (col >= 0 && col < numCols) {
1528 [ - + - - ]: 1 : QDate clickDate = (m_viewMode == WeekView) ? weekStart.addDays(col)
1529 : 1 : : m_currentDate;
1530 [ + - ]: 1 : auto it2 = m_eventsByDate.constFind(clickDate);
1531 [ + - + - ]: 1 : if (it2 != m_eventsByDate.constEnd()) {
1532 : 1 : int adRow = (pos.y() - topY) / kAllDayRowH;
1533 : 1 : int adIdx = 0;
1534 [ + - ]: 1 : for (const auto &e : it2.value()) {
1535 [ - + ]: 1 : if (!e.allDay) continue;
1536 [ + - ]: 1 : if (adIdx == adRow) return e;
1537 : 0 : ++adIdx;
1538 : : }
1539 : : }
1540 : : }
1541 : : }
1542 : :
1543 : : // Then check timed events
1544 [ + - ]: 3 : QDateTime clickTime = timeAtPosition(pos);
1545 [ + - - + ]: 3 : if (!clickTime.isValid())
1546 : 0 : return {};
1547 [ + - ]: 3 : QDate clickDate = clickTime.date();
1548 [ + - ]: 3 : auto it = m_eventsByDate.constFind(clickDate);
1549 [ + - + - ]: 3 : if (it == m_eventsByDate.constEnd())
1550 : 3 : return {};
1551 : :
1552 [ # # ]: 0 : for (const auto &ev : it.value()) {
1553 [ # # ]: 0 : if (ev.allDay) continue;
1554 [ # # # # : 0 : if (ev.dtStart.isValid() && ev.dtEnd.isValid() &&
# # # # ]
1555 [ # # # # : 0 : clickTime >= ev.dtStart && clickTime < ev.dtEnd) {
# # # # #
# ]
1556 : 0 : return ev;
1557 : : }
1558 : : }
1559 [ - + ]: 3 : }
1560 : 0 : return {};
1561 : 10 : }
1562 : :
1563 : 3 : void CalendarWidget::showCalendarFilterMenu(const QPoint &globalPos) {
1564 [ - + ]: 3 : if (!m_store) return;
1565 [ + - ]: 3 : auto calendars = m_store->allCalendars();
1566 [ - + ]: 3 : if (calendars.isEmpty()) return;
1567 : :
1568 [ + - ]: 3 : QMenu menu(this);
1569 [ + - + - ]: 3 : menu.setTitle(tr("Calendar filter"));
1570 [ + - + - : 9 : for (const auto &cal : calendars) {
+ + ]
1571 [ - + + - ]: 6 : auto *action = menu.addAction(
1572 : 6 : cal.displayName.isEmpty() ? cal.path : cal.displayName);
1573 [ + - ]: 6 : action->setCheckable(true);
1574 [ + + + + : 10 : action->setChecked(m_visibleCalendars.isEmpty() ||
+ - ]
1575 : 4 : m_visibleCalendars.contains(cal.path));
1576 [ + - ]: 6 : action->setData(cal.path);
1577 : : // Color swatch. T-71.5a: dim the swatch (low alpha) when the calendar
1578 : : // is hidden — a second visual cue alongside the check state, so the
1579 : : // status is legible even before QMenu paints the indicator.
1580 [ + - ]: 6 : QColor color = eventColor(cal.color, cal.path);
1581 [ + + + + ]: 10 : const bool visible = m_visibleCalendars.isEmpty() ||
1582 : 4 : m_visibleCalendars.contains(cal.path);
1583 [ + + + - ]: 6 : if (!visible) color.setAlpha(80);
1584 [ + - ]: 6 : QPixmap px(12, 12);
1585 [ + - ]: 6 : px.fill(color);
1586 [ + - + - ]: 6 : action->setIcon(QIcon(px));
1587 : 6 : }
1588 [ + - ]: 3 : menu.addSeparator();
1589 [ + - + - ]: 3 : auto *allAction = menu.addAction(tr("Show all"));
1590 : :
1591 [ + - ]: 3 : auto *result = menu.exec(globalPos);
1592 [ + + ]: 3 : if (result == allAction) {
1593 : 1 : m_visibleCalendars.clear();
1594 [ + - ]: 2 : } else if (result) {
1595 [ + - + - ]: 2 : QString path = result->data().toString();
1596 : : // Build visible set if currently showing all
1597 [ + + ]: 2 : if (m_visibleCalendars.isEmpty()) {
1598 [ + - + - : 3 : for (const auto &c : calendars)
+ + ]
1599 [ + - ]: 2 : m_visibleCalendars.insert(c.path);
1600 : : }
1601 [ + + ]: 2 : if (m_visibleCalendars.contains(path))
1602 [ + - ]: 1 : m_visibleCalendars.remove(path);
1603 : : else
1604 [ + - ]: 1 : m_visibleCalendars.insert(path);
1605 : : // If all are selected, clear to "show all" mode
1606 [ + + ]: 2 : if (m_visibleCalendars.size() == calendars.size())
1607 : 1 : m_visibleCalendars.clear();
1608 : 2 : }
1609 : : // Persist
1610 [ + - ]: 3 : QSettings s;
1611 [ + - ]: 3 : QStringList visList(m_visibleCalendars.begin(),
1612 [ + - + - ]: 6 : m_visibleCalendars.end());
1613 [ + - ]: 6 : s.setValue(QStringLiteral("calendar/visibleCalendars"), visList);
1614 [ + - ]: 3 : loadEventsForVisibleRange();
1615 [ + - ]: 3 : update();
1616 [ + - ]: 3 : }
|