File indexing completed on 2024-06-23 04:42:35

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