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: 95.2 % 1181 1124
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 49 49
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 56.6 % 2472 1398

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

Generated by: LCOV version 2.0-1