MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - CalendarWidget.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 94.4 % 1146 1082
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 45 45
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 55.8 % 2278 1271

             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 : }
        

Generated by: LCOV version 2.0-1