File indexing completed on 2025-02-16 04:56:34

0001 // SPDX-FileCopyrightText: 2021 Claudio Cambra <claudio.cambra@gmail.com>
0002 // SPDX-License-Identifier: LGPL-2.0-or-later
0003 
0004 #include "hourlyincidencemodel.h"
0005 #include <QTimeZone>
0006 #include <cmath>
0007 
0008 using namespace std::chrono_literals;
0009 
0010 HourlyIncidenceModel::HourlyIncidenceModel(QObject *parent)
0011     : QAbstractListModel(parent)
0012 {
0013     mRefreshTimer.setSingleShot(true);
0014     mRefreshTimer.setInterval(200ms);
0015     mRefreshTimer.callOnTimeout(this, [this] {
0016         Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {IncidencesRole});
0017     });
0018 }
0019 
0020 int HourlyIncidenceModel::rowCount(const QModelIndex &parent) const
0021 {
0022     // Number of weeks
0023     if (parent.isValid()) {
0024         return 0;
0025     }
0026 
0027     if (mSourceModel) {
0028         return qMax(mSourceModel->length(), 1);
0029     }
0030     return 0;
0031 }
0032 
0033 static double getDuration(const QDateTime &start, const QDateTime &end, int periodLength)
0034 {
0035     return ((start.secsTo(end) * 1.0) / 60.0) / periodLength;
0036 }
0037 
0038 // We first sort all occurrences so we get all-day first (sorted by duration),
0039 // and then the rest sorted by start-date.
0040 QList<QModelIndex> HourlyIncidenceModel::sortedIncidencesFromSourceModel(const QDateTime &rowStart) const
0041 {
0042     // Don't add days if we are going for a daily period
0043     const auto rowEnd = rowStart.date().endOfDay();
0044     QList<QModelIndex> sorted;
0045     sorted.reserve(mSourceModel->rowCount());
0046     // Get incidences from source model
0047     for (int row = 0; row < mSourceModel->rowCount(); row++) {
0048         const auto srcIdx = mSourceModel->index(row, 0, {});
0049         const auto start = srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone());
0050         const auto end = srcIdx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone());
0051 
0052         // Skip incidences not part of the week
0053         if (end < rowStart || start > rowEnd) {
0054             // qCWarning(MERKURO_CALENDAR_LOG) << "Skipping because not part of this week";
0055             continue;
0056         }
0057 
0058         if (m_filters.testFlag(NoAllDay) && srcIdx.data(IncidenceOccurrenceModel::AllDay).toBool()) {
0059             continue;
0060         }
0061 
0062         if (m_filters.testFlag(NoMultiDay) && srcIdx.data(IncidenceOccurrenceModel::Duration).value<KCalendarCore::Duration>().asDays() >= 1) {
0063             continue;
0064         }
0065 
0066         const auto incidencePtr = srcIdx.data(IncidenceOccurrenceModel::IncidencePtr).value<KCalendarCore::Incidence::Ptr>();
0067         const auto incidenceIsTodo = incidencePtr->type() == Incidence::TypeTodo;
0068         if (!m_showTodos && incidenceIsTodo) {
0069             continue;
0070         }
0071 
0072         if (m_showTodos && incidenceIsTodo && !m_showSubTodos && !incidencePtr->relatedTo().isEmpty()) {
0073             continue;
0074         }
0075         // qCWarning(MERKURO_CALENDAR_LOG) << "found " << srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime() <<
0076         // srcIdx.data(IncidenceOccurrenceModel::Summary).toString();
0077         sorted.append(srcIdx);
0078     }
0079 
0080     // Sort incidences by date
0081     std::sort(sorted.begin(), sorted.end(), [&](const QModelIndex &left, const QModelIndex &right) {
0082         // All-day first
0083         const auto leftAllDay = left.data(IncidenceOccurrenceModel::AllDay).toBool();
0084         const auto rightAllDay = right.data(IncidenceOccurrenceModel::AllDay).toBool();
0085 
0086         const auto leftDt = left.data(IncidenceOccurrenceModel::StartTime).toDateTime();
0087         const auto rightDt = right.data(IncidenceOccurrenceModel::StartTime).toDateTime();
0088 
0089         if (leftAllDay && !rightAllDay) {
0090             return true;
0091         }
0092         if (!leftAllDay && rightAllDay) {
0093             return false;
0094         }
0095 
0096         // The rest sorted by start date
0097         return leftDt < rightDt;
0098     });
0099 
0100     return sorted;
0101 }
0102 
0103 /*
0104  * Layout the lines:
0105  *
0106  * The line grouping algorithm then always picks the first incidence,
0107  * and tries to add more to the same line.
0108  *
0109  */
0110 QVariantList HourlyIncidenceModel::layoutLines(const QDateTime &rowStart) const
0111 {
0112     QList<QModelIndex> sorted = sortedIncidencesFromSourceModel(rowStart);
0113     const auto rowEnd = rowStart.date().endOfDay();
0114     const int periodsPerDay = (24 * 60) / mPeriodLength;
0115 
0116     // for (const auto &srcIdx : sorted) {
0117     //     qCWarning(MERKURO_CALENDAR_LOG) << "sorted " << srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime() <<
0118     //     srcIdx.data(IncidenceOccurrenceModel::Summary).toString()
0119     //     << srcIdx.data(IncidenceOccurrenceModel::AllDay).toBool();
0120     // }
0121     QVariantList result;
0122 
0123     auto addToResults = [&result](const QModelIndex &idx, double start, double duration) {
0124         auto incidenceMap = QVariantMap{
0125             {QStringLiteral("text"), idx.data(IncidenceOccurrenceModel::Summary)},
0126             {QStringLiteral("description"), idx.data(IncidenceOccurrenceModel::Description)},
0127             {QStringLiteral("location"), idx.data(IncidenceOccurrenceModel::Location)},
0128             {QStringLiteral("startTime"), idx.data(IncidenceOccurrenceModel::StartTime)},
0129             {QStringLiteral("endTime"), idx.data(IncidenceOccurrenceModel::EndTime)},
0130             {QStringLiteral("allDay"), idx.data(IncidenceOccurrenceModel::AllDay)},
0131             {QStringLiteral("todoCompleted"), idx.data(IncidenceOccurrenceModel::TodoCompleted)},
0132             {QStringLiteral("priority"), idx.data(IncidenceOccurrenceModel::Priority)},
0133             {QStringLiteral("starts"), start},
0134             {QStringLiteral("duration"), duration},
0135             {QStringLiteral("durationString"), idx.data(IncidenceOccurrenceModel::DurationString)},
0136             {QStringLiteral("recurs"), idx.data(IncidenceOccurrenceModel::Recurs)},
0137             {QStringLiteral("hasReminders"), idx.data(IncidenceOccurrenceModel::HasReminders)},
0138             {QStringLiteral("isOverdue"), idx.data(IncidenceOccurrenceModel::IsOverdue)},
0139             {QStringLiteral("isReadOnly"), idx.data(IncidenceOccurrenceModel::IsReadOnly)},
0140             {QStringLiteral("color"), idx.data(IncidenceOccurrenceModel::Color)},
0141             {QStringLiteral("collectionId"), idx.data(IncidenceOccurrenceModel::CollectionId)},
0142             {QStringLiteral("incidenceId"), idx.data(IncidenceOccurrenceModel::IncidenceId)},
0143             {QStringLiteral("incidenceType"), idx.data(IncidenceOccurrenceModel::IncidenceType)},
0144             {QStringLiteral("incidenceTypeStr"), idx.data(IncidenceOccurrenceModel::IncidenceTypeStr)},
0145             {QStringLiteral("incidenceTypeIcon"), idx.data(IncidenceOccurrenceModel::IncidenceTypeIcon)},
0146             {QStringLiteral("incidencePtr"), idx.data(IncidenceOccurrenceModel::IncidencePtr)},
0147             {QStringLiteral("incidenceOccurrence"), idx.data(IncidenceOccurrenceModel::IncidenceOccurrence)},
0148         };
0149 
0150         result.append(incidenceMap);
0151     };
0152 
0153     // Since our hourly view displays by the minute, we need to know how many incidences there are in each minute.
0154     // This hash's keys are the minute of the given day, as the view has accuracy down to the minute. Each value
0155     // for each key is the number of incidences that occupy that minute's spot.
0156     QHash<int, int> takenSpaces;
0157     auto setTakenSpaces = [&](int start, int end) {
0158         for (int i = start; i < end; i++) {
0159             if (!takenSpaces.contains(i)) {
0160                 takenSpaces[i] = 1;
0161             } else {
0162                 takenSpaces[i]++;
0163             }
0164         }
0165     };
0166 
0167     while (!sorted.isEmpty()) {
0168         const auto idx = sorted.takeFirst();
0169         const auto startDT = idx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) > rowStart
0170             ? idx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone())
0171             : rowStart;
0172         const auto endDT = idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) < rowEnd
0173             ? idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone())
0174             : rowEnd;
0175         // Need to convert ints into doubles to get more accurate starting positions
0176         // We get a start position relative to the number of period spaces there are in a day
0177         const auto start = ((startDT.time().hour() * 1.0) * (60.0 / mPeriodLength)) + ((startDT.time().minute() * 1.0) / mPeriodLength);
0178         auto duration = // Give a minimum acceptable height or otherwise have unclickable incidence
0179             qMax(getDuration(startDT, idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()), mPeriodLength), 1.0);
0180 
0181         // Make sure incidence doesn't extend past the end of the day
0182         if (start + duration > periodsPerDay) {
0183             duration = periodsPerDay - start;
0184         }
0185 
0186         const auto realEndMinutesFromDayStart = qMin((endDT.time().hour() * 60) + endDT.time().minute(), 24 * 60 * 60);
0187         // Todos likely won't have end date
0188         const auto startMinutesFromDayStart =
0189             startDT.isValid() ? (startDT.time().hour() * 60) + startDT.time().minute() : qMax(realEndMinutesFromDayStart - mPeriodLength, 0);
0190         const auto displayedEndMinutesFromDayStart = floor(startMinutesFromDayStart + (mPeriodLength * duration));
0191 
0192         addToResults(idx, start, duration);
0193         setTakenSpaces(startMinutesFromDayStart, displayedEndMinutesFromDayStart);
0194     }
0195 
0196     QHash<int, double> takenWidth; // We need this for potential movers
0197     QHash<int, double> startX;
0198     // Potential movers are incidences that are placed at first but might need to be moved later as more incidences get placed to
0199     // the left of them. Rather than loop more than once over our incidences, we create a record of these and then deal with them
0200     // later, storing the needed data in a struct.
0201     struct PotentialMover {
0202         QVariantMap incidenceMap;
0203         int resultIterator;
0204         int startMinutesFromDayStart;
0205         int endMinutesFromDayStart;
0206     };
0207     QList<PotentialMover> potentialMovers;
0208 
0209     // Calculate the width and x position of each incidence rectangle
0210     for (int i = 0; i < result.length(); i++) {
0211         auto incidence = result[i].value<QVariantMap>();
0212         int concurrentIncidences = 1;
0213 
0214         const auto startDT = incidence[QLatin1StringView("startTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) > rowStart
0215             ? incidence[QLatin1StringView("startTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone())
0216             : rowStart;
0217         const auto endDT = incidence[QLatin1StringView("endTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) < rowEnd
0218             ? incidence[QLatin1StringView("endTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone())
0219             : rowEnd;
0220         const auto duration = incidence[QLatin1StringView("duration")].toDouble();
0221 
0222         // We need a "real" and "displayed" end time for two reasons:
0223         // 1. We need the real end minutes to give a fake start time to todos which do not have a start time
0224         // 2. We need the displayed end minutes to be able to properly position those incidences which are displayed as longer
0225         // than they actually are
0226         const auto realEndMinutesFromDayStart = qMin((endDT.time().hour() * 60) + endDT.time().minute(), 24 * 60 * 60);
0227         // Todos likely won't have end date
0228         const auto startMinutesFromDayStart =
0229             startDT.isValid() ? (startDT.time().hour() * 60) + startDT.time().minute() : qMax(realEndMinutesFromDayStart - mPeriodLength, 0);
0230         const int displayedEndMinutesFromDayStart = floor(startMinutesFromDayStart + (mPeriodLength * duration));
0231 
0232         // Get max number of incidences that happen at the same time as this
0233         // (there can be different numbers of concurrent incidences during the time)
0234         for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart; i++) {
0235             concurrentIncidences = qMax(concurrentIncidences, takenSpaces[i]);
0236         }
0237 
0238         incidence[QLatin1StringView("maxConcurrentIncidences")] = concurrentIncidences;
0239         double widthShare = 1.0 / (concurrentIncidences * 1.0); // Width as a fraction of the whole day column width
0240         incidence[QLatin1StringView("widthShare")] = widthShare;
0241 
0242         // This is the value that the QML view will use to position the incidence rectangle on the day column's X axis.
0243         double priorTakenWidthShare = 0.0;
0244         // If we have empty space at the very left of the column we want to take advantage and place an incidence there
0245         // even if there have been other incidences that take up space further to the right. For this we use minStartX,
0246         // which gathers the lowest x starting position in a given minute; if this is higher than 0, it means that there
0247         // is empty space at the left of the day column.
0248         double minStartX = 1.0;
0249 
0250         for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart - 1; i++) {
0251             // If this is the first incidence that has taken up this minute position, set details
0252             if (!startX.contains(i)) {
0253                 takenWidth[i] = widthShare;
0254                 startX[i] = priorTakenWidthShare;
0255             } else {
0256                 priorTakenWidthShare = qMax(priorTakenWidthShare, takenWidth[i]); // Get maximum prior space taken so we do not overlap with anything
0257                 minStartX = qMin(minStartX, startX[i]);
0258 
0259                 if (startX[i] > 0) {
0260                     takenWidth[i] = widthShare; // Reset as there is space available at the beginning of the column
0261                 } else {
0262                     takenWidth[i] += widthShare; // Increase the taken width at this minute position
0263                 }
0264             }
0265         }
0266 
0267         if (minStartX > 0) {
0268             priorTakenWidthShare = 0;
0269             for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart; i++) {
0270                 startX[i] = 0;
0271             }
0272         }
0273 
0274         incidence[QLatin1StringView("priorTakenWidthShare")] = priorTakenWidthShare;
0275 
0276         if (takenSpaces[startMinutesFromDayStart] < takenSpaces[displayedEndMinutesFromDayStart - 1] && priorTakenWidthShare > 0) {
0277             potentialMovers.append(PotentialMover{incidence, i, startMinutesFromDayStart, displayedEndMinutesFromDayStart});
0278         }
0279 
0280         result[i] = incidence;
0281     }
0282 
0283     for (auto &potentialMover : potentialMovers) {
0284         double maxTakenWidth = 0;
0285         for (int i = potentialMover.startMinutesFromDayStart; i < potentialMover.endMinutesFromDayStart; i++) {
0286             maxTakenWidth = qMax(maxTakenWidth, takenWidth[i]);
0287         }
0288 
0289         if (maxTakenWidth < 0.98) {
0290             potentialMover.incidenceMap[QLatin1StringView("priorTakenWidthShare")] =
0291                 potentialMover.incidenceMap[QLatin1StringView("widthShare")].toDouble() * (takenSpaces[potentialMover.endMinutesFromDayStart - 1] - 1);
0292 
0293             result[potentialMover.resultIterator] = potentialMover.incidenceMap;
0294         }
0295     }
0296 
0297     return result;
0298 }
0299 
0300 QVariant HourlyIncidenceModel::data(const QModelIndex &idx, int role) const
0301 {
0302     Q_ASSERT(hasIndex(idx.row(), idx.column()) && mSourceModel);
0303 
0304     const auto rowStart = mSourceModel->start().addDays(idx.row()).startOfDay();
0305     switch (role) {
0306     case PeriodStartDateTimeRole:
0307         return rowStart;
0308     case IncidencesRole:
0309         return layoutLines(rowStart);
0310     default:
0311         Q_UNREACHABLE();
0312     }
0313 }
0314 
0315 IncidenceOccurrenceModel *HourlyIncidenceModel::model() const
0316 {
0317     return mSourceModel;
0318 }
0319 
0320 void HourlyIncidenceModel::setModel(IncidenceOccurrenceModel *model)
0321 {
0322     beginResetModel();
0323     mSourceModel = model;
0324     Q_EMIT modelChanged();
0325     endResetModel();
0326 
0327     connect(model, &QAbstractItemModel::dataChanged, this, &HourlyIncidenceModel::scheduleReset);
0328     connect(model, &QAbstractItemModel::layoutChanged, this, &HourlyIncidenceModel::scheduleReset);
0329     connect(model, &QAbstractItemModel::modelReset, this, &HourlyIncidenceModel::scheduleReset);
0330     connect(model, &QAbstractItemModel::rowsInserted, this, &HourlyIncidenceModel::scheduleReset);
0331     connect(model, &QAbstractItemModel::rowsMoved, this, &HourlyIncidenceModel::scheduleReset);
0332     connect(model, &QAbstractItemModel::rowsRemoved, this, &HourlyIncidenceModel::scheduleReset);
0333     connect(model, &IncidenceOccurrenceModel::lengthChanged, this, [this] {
0334         beginResetModel();
0335         endResetModel();
0336     });
0337 }
0338 
0339 void HourlyIncidenceModel::scheduleReset()
0340 {
0341     if (!mRefreshTimer.isActive()) {
0342         mRefreshTimer.start();
0343     }
0344 }
0345 
0346 int HourlyIncidenceModel::periodLength() const
0347 {
0348     return mPeriodLength;
0349 }
0350 
0351 void HourlyIncidenceModel::setPeriodLength(int periodLength)
0352 {
0353     if (mPeriodLength == periodLength) {
0354         return;
0355     }
0356     mPeriodLength = periodLength;
0357     Q_EMIT periodLengthChanged();
0358 
0359     scheduleReset();
0360 }
0361 
0362 HourlyIncidenceModel::Filters HourlyIncidenceModel::filters() const
0363 {
0364     return m_filters;
0365 }
0366 
0367 void HourlyIncidenceModel::setFilters(HourlyIncidenceModel::Filters filters)
0368 {
0369     if (m_filters == filters) {
0370         return;
0371     }
0372     m_filters = filters;
0373     Q_EMIT filtersChanged();
0374 
0375     scheduleReset();
0376 }
0377 
0378 bool HourlyIncidenceModel::showTodos() const
0379 {
0380     return m_showTodos;
0381 }
0382 
0383 void HourlyIncidenceModel::setShowTodos(const bool showTodos)
0384 {
0385     if (showTodos == m_showTodos) {
0386         return;
0387     }
0388 
0389     m_showTodos = showTodos;
0390     Q_EMIT showTodosChanged();
0391 
0392     scheduleReset();
0393 }
0394 
0395 bool HourlyIncidenceModel::showSubTodos() const
0396 {
0397     return m_showSubTodos;
0398 }
0399 
0400 void HourlyIncidenceModel::setShowSubTodos(const bool showSubTodos)
0401 {
0402     if (showSubTodos == m_showSubTodos) {
0403         return;
0404     }
0405 
0406     m_showSubTodos = showSubTodos;
0407     Q_EMIT showSubTodosChanged();
0408 
0409     scheduleReset();
0410 }
0411 
0412 bool HourlyIncidenceModel::active() const
0413 {
0414     return m_active;
0415 }
0416 
0417 void HourlyIncidenceModel::setActive(const bool active)
0418 {
0419     if (active == m_active) {
0420         return;
0421     }
0422 
0423     m_active = active;
0424     Q_EMIT activeChanged();
0425 
0426     if (active && mRefreshTimer.isActive() && std::chrono::milliseconds(mRefreshTimer.remainingTime()) > 200ms) {
0427         Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0));
0428         mRefreshTimer.stop();
0429     }
0430     mRefreshTimer.setInterval(active ? 200ms : 1000ms);
0431 }
0432 
0433 QHash<int, QByteArray> HourlyIncidenceModel::roleNames() const
0434 {
0435     return {
0436         {IncidencesRole, QByteArrayLiteral("incidences")},
0437         {PeriodStartDateTimeRole, QByteArrayLiteral("periodStartDateTime")},
0438     };
0439 }
0440 
0441 #include "moc_hourlyincidencemodel.cpp"