diff --git a/src/Charts/PlanAdherenceWindow.cpp b/src/Charts/PlanAdherenceWindow.cpp
new file mode 100644
index 000000000..5f3e2cb23
--- /dev/null
+++ b/src/Charts/PlanAdherenceWindow.cpp
@@ -0,0 +1,474 @@
+/*
+ * Copyright (c) 2026 Joachim Kohlhammer (joachim.kohlhammer@gmx.de)
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "PlanAdherenceWindow.h"
+
+#include "Athlete.h"
+#include "RideMetadata.h"
+#include "Seasons.h"
+#include "RideItem.h"
+#include "Colors.h"
+#include "IconManager.h"
+#include "PlanAdherence.h"
+
+#define HLO "
"
+#define HLC "
"
+
+
+PlanAdherenceWindow::PlanAdherenceWindow(Context *context)
+: GcChartWindow(context), context(context)
+{
+ mkControls();
+
+ adherenceView = new PlanAdherenceView();
+
+ titleMainCombo->setCurrentText("Route");
+ titleFallbackCombo->setCurrentText("Workout Code");
+ setMaxDaysBefore(1);
+ setMaxDaysAfter(5);
+ setPreferredStatisticsDisplay(1);
+
+ QVBoxLayout *mainLayout = new QVBoxLayout();
+ setChartLayout(mainLayout);
+ mainLayout->addWidget(adherenceView);
+
+ connect(adherenceView, &PlanAdherenceView::monthChanged, this, &PlanAdherenceWindow::updateActivities);
+ connect(adherenceView, &PlanAdherenceView::viewActivity, this, [this](QString reference, bool planned) {
+ for (RideItem *rideItem : this->context->athlete->rideCache->rides()) {
+ if (rideItem == nullptr || rideItem->fileName.isEmpty()) {
+ continue;
+ }
+ if (rideItem->fileName == reference && rideItem->planned == planned) {
+ this->context->notifyRideSelected(rideItem);
+ this->context->mainWindow->selectAnalysis();
+ break;
+ }
+ }
+ });
+ connect(context->athlete->rideCache, QOverload::of(&RideCache::itemChanged), this, &PlanAdherenceWindow::updateActivitiesIfInRange);
+ connect(context->athlete->seasons, &Seasons::seasonsChanged, this, [this]() {
+ DateRange dr(this->context->currentSeason()->getStart(), this->context->currentSeason()->getEnd(), this->context->currentSeason()->getName());
+ adherenceView->setDateRange(dr);
+ });
+ connect(context, &Context::seasonSelected, this, [this](Season const *season, bool changed) {
+ if (changed || first) {
+ first = false;
+ DateRange dr(season->getStart(), season->getEnd(), season->getName());
+ adherenceView->setDateRange(dr);
+ }
+ });
+ connect(context, &Context::seasonSelected, this, &PlanAdherenceWindow::updateActivities);
+ connect(context, &Context::filterChanged, this, &PlanAdherenceWindow::updateActivities);
+ connect(context, &Context::homeFilterChanged, this, &PlanAdherenceWindow::updateActivities);
+ connect(context, &Context::rideAdded, this, &PlanAdherenceWindow::updateActivitiesIfInRange);
+ connect(context, &Context::rideDeleted, this, &PlanAdherenceWindow::updateActivitiesIfInRange);
+ connect(context, &Context::rideChanged, this, &PlanAdherenceWindow::updateActivitiesIfInRange);
+ connect(context, &Context::configChanged, this, &PlanAdherenceWindow::configChanged);
+
+ QTimer::singleShot(0, this, [this]() {
+ configChanged(CONFIG_APPEARANCE);
+ if (this->context->currentSeason() != nullptr) {
+ DateRange dr(this->context->currentSeason()->getStart(), this->context->currentSeason()->getEnd(), this->context->currentSeason()->getName());
+ adherenceView->setDateRange(dr);
+ }
+ });
+}
+
+
+QString
+PlanAdherenceWindow::getTitleMainField
+() const
+{
+ return titleMainCombo->currentText();
+}
+
+
+void
+PlanAdherenceWindow::setTitleMainField
+(const QString &name)
+{
+ titleMainCombo->setCurrentText(name);
+}
+
+
+QString
+PlanAdherenceWindow::getTitleFallbackField
+() const
+{
+ return titleFallbackCombo->currentText();
+}
+
+
+void
+PlanAdherenceWindow::setTitleFallbackField
+(const QString &name)
+{
+ titleFallbackCombo->setCurrentText(name);
+}
+
+
+int
+PlanAdherenceWindow::getMaxDaysBefore
+() const
+{
+ return maxDaysBeforeSpin->value();
+}
+
+
+void
+PlanAdherenceWindow::setMaxDaysBefore
+(int days)
+{
+ QSignalBlocker blocker(maxDaysBeforeSpin);
+ maxDaysBeforeSpin->setValue(days);
+ if (adherenceView != nullptr) {
+ adherenceView->setMinAllowedOffset(-maxDaysBeforeSpin->value());
+ updateActivities();
+ }
+}
+
+
+int
+PlanAdherenceWindow::getMaxDaysAfter
+() const
+{
+ return maxDaysAfterSpin->value();
+}
+
+
+void
+PlanAdherenceWindow::setMaxDaysAfter
+(int days)
+{
+ QSignalBlocker blocker(maxDaysAfterSpin);
+ maxDaysAfterSpin->setValue(days);
+ if (adherenceView != nullptr) {
+ adherenceView->setMaxAllowedOffset(maxDaysAfterSpin->value());
+ updateActivities();
+ }
+}
+
+
+int
+PlanAdherenceWindow::getPreferredStatisticsDisplay
+() const
+{
+ return preferredStatisticsDisplay->currentIndex();
+}
+
+
+void
+PlanAdherenceWindow::setPreferredStatisticsDisplay
+(int mode)
+{
+ QSignalBlocker blocker(preferredStatisticsDisplay);
+ preferredStatisticsDisplay->setCurrentIndex(mode);
+ if (adherenceView != nullptr) {
+ adherenceView->setPreferredStatisticsDisplay(mode);
+ }
+}
+
+
+void
+PlanAdherenceWindow::configChanged
+(qint32 what)
+{
+ bool refreshActivities = false;
+ if ( (what & CONFIG_FIELDS)
+ || (what & CONFIG_USERMETRICS)) {
+ updateTitleConfigCombos();
+ }
+ if (what & CONFIG_APPEARANCE) {
+ // change colors to reflect preferences
+ setProperty("color", GColor(CPLOTBACKGROUND));
+
+ QColor activeBase = GColor(CPLOTBACKGROUND);
+ QColor activeWindow = activeBase;
+ QColor activeText = GCColor::invertColor(activeBase);
+ QColor activeHl = GColor(CCALCURRENT);
+ QColor activeHlText = GCColor::invertColor(activeHl);
+ QColor alternateBg = GCColor::inactiveColor(activeBase, 0.2);
+ QColor inactiveText = GCColor::inactiveColor(activeText, 1.5);
+ QColor activeButtonBg = activeBase;
+ QColor disabledButtonBg = alternateBg;
+ if (activeBase.lightness() < 20) {
+ activeWindow = GCColor::inactiveColor(activeWindow, 0.2);
+ activeButtonBg = alternateBg;
+ disabledButtonBg = GCColor::inactiveColor(activeButtonBg, 0.3);
+ inactiveText = GCColor::inactiveColor(activeText, 2.5);
+ }
+
+ palette.setColor(QPalette::Active, QPalette::Window, activeWindow);
+ palette.setColor(QPalette::Active, QPalette::WindowText, activeText);
+ palette.setColor(QPalette::Active, QPalette::Base, activeBase);
+ palette.setColor(QPalette::Active, QPalette::AlternateBase, alternateBg);
+ palette.setColor(QPalette::Active, QPalette::Text, activeText);
+ palette.setColor(QPalette::Active, QPalette::Highlight, activeHl);
+ palette.setColor(QPalette::Active, QPalette::HighlightedText, activeHlText);
+ palette.setColor(QPalette::Active, QPalette::Button, activeButtonBg);
+ palette.setColor(QPalette::Active, QPalette::ButtonText, activeText);
+
+ palette.setColor(QPalette::Inactive, QPalette::Window, activeWindow);
+ palette.setColor(QPalette::Inactive, QPalette::WindowText, activeText);
+ palette.setColor(QPalette::Inactive, QPalette::Base, activeBase);
+ palette.setColor(QPalette::Inactive, QPalette::AlternateBase, alternateBg);
+ palette.setColor(QPalette::Inactive, QPalette::Text, activeText);
+ palette.setColor(QPalette::Inactive, QPalette::Highlight, activeHl);
+ palette.setColor(QPalette::Inactive, QPalette::HighlightedText, activeHlText);
+ palette.setColor(QPalette::Inactive, QPalette::Button, activeButtonBg);
+ palette.setColor(QPalette::Inactive, QPalette::ButtonText, activeText);
+
+ palette.setColor(QPalette::Disabled, QPalette::Window, alternateBg);
+ palette.setColor(QPalette::Disabled, QPalette::WindowText, inactiveText);
+ palette.setColor(QPalette::Disabled, QPalette::Base, alternateBg);
+ palette.setColor(QPalette::Disabled, QPalette::AlternateBase, alternateBg);
+ palette.setColor(QPalette::Disabled, QPalette::Text, inactiveText);
+ palette.setColor(QPalette::Disabled, QPalette::Highlight, activeHl);
+ palette.setColor(QPalette::Disabled, QPalette::HighlightedText, activeHlText);
+ palette.setColor(QPalette::Disabled, QPalette::Button, disabledButtonBg);
+ palette.setColor(QPalette::Disabled, QPalette::ButtonText, inactiveText);
+
+ PaletteApplier::setPaletteRecursively(this, palette, true);
+ adherenceView->applyNavIcons();
+ }
+
+ if (refreshActivities) {
+ updateActivities();
+ }
+}
+
+
+bool
+PlanAdherenceWindow::isFiltered
+() const
+{
+ return (context->ishomefiltered || context->isfiltered);
+}
+
+
+void
+PlanAdherenceWindow::mkControls
+()
+{
+ titleMainCombo = new QComboBox();
+ titleFallbackCombo = new QComboBox();
+ updateTitleConfigCombos();
+
+ maxDaysBeforeSpin = new QSpinBox();
+ maxDaysBeforeSpin->setMinimum(1);
+ maxDaysBeforeSpin->setMaximum(14);
+
+ maxDaysAfterSpin = new QSpinBox();
+ maxDaysAfterSpin->setMinimum(1);
+ maxDaysAfterSpin->setMaximum(14);
+
+ preferredStatisticsDisplay = new QComboBox();
+ preferredStatisticsDisplay->addItem(tr("Absolute"));
+ preferredStatisticsDisplay->addItem(tr("Percentage"));
+
+ QFormLayout *controlsForm = newQFormLayout();
+ controlsForm->setContentsMargins(0, 10 * dpiYFactor, 0, 10 * dpiYFactor);
+
+ controlsForm->addRow(new QLabel(HLO + tr("Activity title") + HLC));
+ controlsForm->addRow(tr("Field"), titleMainCombo);
+ controlsForm->addRow(tr("Fallback Field"), titleFallbackCombo);
+ controlsForm->addItem(new QSpacerItem(0, 20 * dpiYFactor));
+ controlsForm->addRow(new QLabel(HLO + tr("Adherence window (days)") + HLC));
+ controlsForm->addRow(tr("Before"), maxDaysBeforeSpin);
+ controlsForm->addRow(tr("After"), maxDaysAfterSpin);
+ controlsForm->addItem(new QSpacerItem(0, 20 * dpiYFactor));
+ controlsForm->addRow(tr("Statistics display"), preferredStatisticsDisplay);
+
+ connect(titleMainCombo, &QComboBox::currentIndexChanged, this, &PlanAdherenceWindow::updateActivities);
+ connect(titleFallbackCombo, &QComboBox::currentIndexChanged, this, &PlanAdherenceWindow::updateActivities);
+ connect(maxDaysBeforeSpin, &QSpinBox::valueChanged, this, &PlanAdherenceWindow::setMaxDaysBefore);
+ connect(maxDaysAfterSpin, &QSpinBox::valueChanged, this, &PlanAdherenceWindow::setMaxDaysAfter);
+ connect(preferredStatisticsDisplay, &QComboBox::currentIndexChanged, this, &PlanAdherenceWindow::setPreferredStatisticsDisplay);
+
+ setControls(centerLayoutInWidget(controlsForm));
+}
+
+
+void
+PlanAdherenceWindow::updateTitleConfigCombos
+()
+{
+ QString mainField = getTitleMainField();
+ QString fallbackField = getTitleFallbackField();
+
+ QSignalBlocker blocker1(titleMainCombo);
+ QSignalBlocker blocker2(titleFallbackCombo);
+
+ titleMainCombo->clear();
+ titleFallbackCombo->clear();
+ QList fieldsDefs = GlobalContext::context()->rideMetadata->getFields();
+ for (const FieldDefinition &fieldDef : fieldsDefs) {
+ if (fieldDef.isTextField()) {
+ titleMainCombo->addItem(fieldDef.name);
+ titleFallbackCombo->addItem(fieldDef.name);
+ }
+ }
+
+ setTitleMainField(mainField);
+ setTitleFallbackField(fallbackField);
+}
+
+
+QString
+PlanAdherenceWindow::getRideItemTitle
+(RideItem const * const rideItem) const
+{
+ QString title = rideItem->getText(getTitleMainField(), rideItem->getText(getTitleFallbackField(), "")).trimmed();
+ if (title.isEmpty()) {
+ if (! rideItem->sport.isEmpty()) {
+ title = tr("Unnamed %1").arg(rideItem->sport);
+ } else {
+ title = tr("");
+ }
+ }
+ return title;
+}
+
+
+void
+PlanAdherenceWindow::updateActivities
+()
+{
+ QList entries;
+ PlanAdherenceStatistics statistics;
+ PlanAdherenceOffsetRange offsetRange;
+ QDate firstVisible = adherenceView->firstVisibleDay();
+ QDate lastVisible = adherenceView->lastVisibleDay();
+ QDate today = QDate::currentDate();
+ for (RideItem *rideItem : context->athlete->rideCache->rides()) {
+ if ( rideItem == nullptr
+ || (! rideItem->planned && rideItem->hasLinkedActivity())
+ || (context->isfiltered && ! context->filters.contains(rideItem->fileName))
+ || (context->ishomefiltered && ! context->homeFilters.contains(rideItem->fileName))) {
+ continue;
+ }
+ QDate rideDate = rideItem->dateTime.date();
+ QString originalDateString = rideItem->getText("Original Date", "");
+ QDate originalDate(rideDate);
+ if (! originalDateString.isEmpty()) {
+ originalDate = QDate::fromString(originalDateString, "yyyy/MM/dd");
+ if (! originalDate.isValid()) {
+ originalDate = rideDate;
+ }
+ }
+ if ( (firstVisible.isValid() && originalDate < firstVisible)
+ || (lastVisible.isValid() && originalDate > lastVisible)) {
+ continue;
+ }
+
+ PlanAdherenceEntry entry;
+
+ entry.titlePrimary = getRideItemTitle(rideItem);
+ entry.iconFile = IconManager::instance().getFilepath(rideItem);
+
+ entry.date = originalDate;
+ entry.isPlanned = rideItem->planned;
+ if (entry.isPlanned) {
+ entry.plannedReference = rideItem->fileName;
+ entry.color = GColor(CCALPLANNED);
+ } else {
+ entry.actualReference = rideItem->fileName;
+ if (rideItem->color.alpha() < 255) {
+ entry.color = GCColor::invertColor(GColor(CPLOTBACKGROUND));
+ } else {
+ entry.color = rideItem->color;
+ }
+ }
+
+ if (entry.isPlanned && originalDate != rideDate) {
+ entry.shiftOffset = originalDate.daysTo(rideDate);
+ offsetRange.min = std::min(offsetRange.min, entry.shiftOffset.value());
+ offsetRange.max = std::max(offsetRange.max, entry.shiftOffset.value());
+ } else {
+ entry.shiftOffset.reset();
+ }
+
+ RideItem *linkedItem = nullptr;
+ if (! rideItem->getLinkedFileName().isEmpty()) {
+ linkedItem = context->athlete->rideCache->getRide(rideItem->getLinkedFileName());
+ }
+ if (entry.isPlanned && linkedItem != nullptr) {
+ if (linkedItem->color.alpha() < 255) {
+ entry.color = GCColor::invertColor(GColor(CPLOTBACKGROUND));
+ } else {
+ entry.color = linkedItem->color;
+ }
+ entry.titleSecondary = getRideItemTitle(linkedItem);
+ entry.actualReference = linkedItem->fileName;
+ entry.actualOffset = originalDate.daysTo(linkedItem->dateTime.date());
+ offsetRange.min = std::min(offsetRange.min, entry.actualOffset.value());
+ offsetRange.max = std::max(offsetRange.max, entry.actualOffset.value());
+ } else {
+ entry.actualOffset.reset();
+ }
+
+ entries << entry;
+
+ ++statistics.totalAbs;
+ if (entry.isPlanned) {
+ ++statistics.plannedAbs;
+ if (entry.shiftOffset != std::nullopt) {
+ ++statistics.shiftedAbs;
+ statistics.totalShiftDaysAbs += std::abs(entry.shiftOffset.value());
+ }
+ if (entry.actualOffset != std::nullopt) {
+ if (entry.actualOffset.value() == 0) {
+ ++statistics.onTimeAbs;
+ }
+ } else if (entry.date < today) {
+ ++statistics.missedAbs;
+ }
+ } else {
+ ++statistics.unplannedAbs;
+ }
+ }
+ std::sort(entries.begin(), entries.end(), [](const PlanAdherenceEntry &a, const PlanAdherenceEntry &b) {
+ return a.date < b.date;
+ });
+ if (statistics.totalAbs > 0) {
+ statistics.plannedRel = 100.0 * statistics.plannedAbs / statistics.totalAbs;
+ statistics.unplannedRel = 100.0 * statistics.unplannedAbs / statistics.totalAbs;
+ }
+ if (statistics.plannedAbs > 0) {
+ statistics.onTimeRel = 100.0 * statistics.onTimeAbs / statistics.plannedAbs;
+ statistics.missedRel = 100.0 * statistics.missedAbs / statistics.plannedAbs;
+ statistics.shiftedRel = 100.0 * statistics.shiftedAbs / statistics.plannedAbs;
+ }
+ if (statistics.shiftedAbs > 0) {
+ statistics.avgShift = statistics.totalShiftDaysAbs / static_cast(statistics.shiftedAbs);
+ }
+
+ adherenceView->fillEntries(entries, statistics, offsetRange, isFiltered());
+}
+
+
+void
+PlanAdherenceWindow::updateActivitiesIfInRange
+(RideItem *rideItem)
+{
+ if ( rideItem != nullptr
+ && rideItem->dateTime.date() >= adherenceView->firstVisibleDay()
+ && rideItem->dateTime.date() <= adherenceView->lastVisibleDay()) {
+ updateActivities();
+ }
+}
diff --git a/src/Charts/PlanAdherenceWindow.h b/src/Charts/PlanAdherenceWindow.h
new file mode 100644
index 000000000..4f5d39d08
--- /dev/null
+++ b/src/Charts/PlanAdherenceWindow.h
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2026 Joachim Kohlhammer (joachim.kohlhammer@gmx.de)
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef _GC_PlanAdherenceWindow_h
+#define _GC_PlanAdherenceWindow_h
+
+#include "GoldenCheetah.h"
+
+#include
+
+#include "Context.h"
+#include "PlanAdherence.h"
+
+
+class PlanAdherenceWindow : public GcChartWindow
+{
+ Q_OBJECT
+
+ Q_PROPERTY(QString titleMainField READ getTitleMainField WRITE setTitleMainField USER true)
+ Q_PROPERTY(QString titleFallbackField READ getTitleFallbackField WRITE setTitleFallbackField USER true)
+ Q_PROPERTY(int maxDaysBefore READ getMaxDaysBefore WRITE setMaxDaysBefore USER true)
+ Q_PROPERTY(int mayDaysAfter READ getMaxDaysAfter WRITE setMaxDaysAfter USER true)
+ Q_PROPERTY(int preferredStatisticsDisplay READ getPreferredStatisticsDisplay WRITE setPreferredStatisticsDisplay USER true)
+
+ public:
+ PlanAdherenceWindow(Context *context);
+
+ QString getTitleMainField() const;
+ QString getTitleFallbackField() const;
+ int getMaxDaysBefore() const;
+ int getMaxDaysAfter() const;
+ int getPreferredStatisticsDisplay() const;
+
+ public slots:
+ void setTitleMainField(const QString &name);
+ void setTitleFallbackField(const QString &name);
+ void setMaxDaysBefore(int days);
+ void setMaxDaysAfter(int days);
+ void setPreferredStatisticsDisplay(int mode);
+
+ void configChanged(qint32);
+
+ private:
+ Context *context;
+ PlanAdherenceView *adherenceView = nullptr;
+ bool first = true;
+
+ QPalette palette;
+
+ QComboBox *titleMainCombo;
+ QComboBox *titleFallbackCombo;
+ QSpinBox *maxDaysBeforeSpin;
+ QSpinBox *maxDaysAfterSpin;
+ QComboBox *preferredStatisticsDisplay;
+
+ bool isFiltered() const;
+ void mkControls();
+ void updateTitleConfigCombos();
+ QString getRideItemTitle(RideItem const * const rideItem) const;
+
+ private slots:
+ void updateActivities();
+ void updateActivitiesIfInRange(RideItem *rideItem);
+};
+
+#endif
diff --git a/src/Gui/CalendarData.h b/src/Gui/CalendarData.h
index c73707f47..15a89f890 100644
--- a/src/Gui/CalendarData.h
+++ b/src/Gui/CalendarData.h
@@ -26,6 +26,7 @@
#include
#include
#include
+
#include
#define ENTRY_TYPE_ACTUAL_ACTIVITY 0
diff --git a/src/Gui/Colors.cpp b/src/Gui/Colors.cpp
index 09d21b2a2..fb61257da 100644
--- a/src/Gui/Colors.cpp
+++ b/src/Gui/Colors.cpp
@@ -1085,6 +1085,15 @@ QColor GCColor::getThemeColor(const ColorTheme& theme, int colorIdx)
return color;
}
+QColor
+GCColor::getSuccessColor(const QPalette &palette)
+{
+ if (isPaletteDark(palette)) {
+ return QColor("#4CAF50");
+ }
+ return QColor("#27ae60");
+}
+
void
GCColor::applyTheme(int index)
{
diff --git a/src/Gui/Colors.h b/src/Gui/Colors.h
index cfad686fa..14d6a29df 100644
--- a/src/Gui/Colors.h
+++ b/src/Gui/Colors.h
@@ -148,6 +148,7 @@ class GCColor : public QObject
static Themes &themes();
static void applyTheme(int index);
static QColor getThemeColor(const ColorTheme& theme, int colorIdx);
+ static QColor getSuccessColor(const QPalette &palette);
// for styling things with current preferences
static QLinearGradient linearGradient(int size, bool active, bool alternate=false);
diff --git a/src/Gui/GcWindowRegistry.cpp b/src/Gui/GcWindowRegistry.cpp
index 1637b0032..ef7b1644a 100644
--- a/src/Gui/GcWindowRegistry.cpp
+++ b/src/Gui/GcWindowRegistry.cpp
@@ -45,6 +45,7 @@
#include "LiveMapWebPageWindow.h"
#include "CalendarWindow.h"
#include "AgendaWindow.h"
+#include "PlanAdherenceWindow.h"
#ifdef GC_WANT_R
#include "RChart.h"
#endif
@@ -114,6 +115,7 @@ GcWindowRegistry::initialize()
{ GcViewType::VIEW_ANALYSIS|GcViewType::VIEW_TRENDS|GcViewType::VIEW_TRAIN, tr("Web page"),GcWindowTypes::WebPageWindow },
{ GcViewType::VIEW_TRENDS|GcViewType::VIEW_PLAN, tr("Calendar"),GcWindowTypes::Calendar },
{ GcViewType::VIEW_TRENDS|GcViewType::VIEW_PLAN, tr("Agenda"),GcWindowTypes::Agenda },
+ { GcViewType::VIEW_PLAN, tr("Plan Adherence"),GcWindowTypes::PlanAdherence },
{ GcViewType::NO_VIEW_SET, "", GcWindowTypes::None }};
// initialize the global registry
GcWindows = GcWindowsInit;
@@ -264,6 +266,7 @@ GcWindowRegistry::newGcWindow(GcWinID id, Context *context)
case GcWindowTypes::Diary:
case GcWindowTypes::Calendar: returning = new CalendarWindow(context); break;
case GcWindowTypes::Agenda: returning = new AgendaWindow(context); break;
+ case GcWindowTypes::PlanAdherence: returning = new PlanAdherenceWindow(context); break;
default: return NULL; break;
}
if (returning) returning->setProperty("type", QVariant::fromValue(id));
diff --git a/src/Gui/GcWindowRegistry.h b/src/Gui/GcWindowRegistry.h
index 04c33138a..1eff8fd56 100644
--- a/src/Gui/GcWindowRegistry.h
+++ b/src/Gui/GcWindowRegistry.h
@@ -82,7 +82,8 @@ enum gcwinid {
Agenda=53,
UserPlan=54,
OverviewPlan=55,
- OverviewPlanBlank = 56
+ OverviewPlanBlank = 56,
+ PlanAdherence = 57
};
};
typedef enum GcWindowTypes::gcwinid GcWinID;
diff --git a/src/Gui/PlanAdherence.cpp b/src/Gui/PlanAdherence.cpp
new file mode 100644
index 000000000..bf64eaf95
--- /dev/null
+++ b/src/Gui/PlanAdherence.cpp
@@ -0,0 +1,1496 @@
+/*
+ * Copyright (c) 2026 Joachim Kohlhammer (joachim.kohlhammer@gmx.de)
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "PlanAdherence.h"
+
+#include
+#include
+
+#include "Colors.h"
+#include "Settings.h"
+
+static void sendFakeHoverMove(QWidget *viewport, const QPoint &pos);
+static void sendFakeHoverLeave(QWidget *viewport);
+
+
+//////////////////////////////////////////////////////////////////////////////
+// StatisticBox
+
+StatisticBox::StatisticBox
+(const QString &title, const QString &description, DisplayState startupPreferred, QWidget *parent)
+: QFrame(parent),
+ description(description),
+ startupPreferredState(startupPreferred),
+ currentPreferredState(startupPreferred),
+ currentState(startupPreferred)
+{
+ setObjectName("statbox");
+
+ setFrameShape(QFrame::StyledPanel);
+ setFrameShadow(QFrame::Raised);
+
+ valueLabel = new QLabel("0", this);
+ valueLabel->setAlignment(Qt::AlignLeft);
+
+ QFont valueFont = valueLabel->font();
+ valueFont.setPointSizeF(valueFont.pointSizeF() * 1.5);
+ valueFont.setBold(true);
+ valueLabel->setFont(valueFont);
+
+ titleLabel = new QLabel(title, this);
+ titleLabel->setObjectName("stattitle");
+ titleLabel->setAlignment(Qt::AlignLeft);
+ QFont titleFont = titleLabel->font();
+ titleFont.setPointSizeF(titleFont.pointSizeF() * 0.7);
+ titleLabel->setFont(titleFont);
+
+ QVBoxLayout *layout = new QVBoxLayout(this);
+ layout->setContentsMargins(12 * dpiXFactor, 8 * dpiYFactor, 12 * dpiXFactor, 8 * dpiYFactor);
+ layout->addWidget(valueLabel);
+ layout->addWidget(titleLabel);
+
+ setMouseTracking(true);
+}
+
+
+void
+StatisticBox::setValue
+(const QString &primary, const QString &secondary)
+{
+ primaryValue = primary;
+ secondaryValue = secondary;
+ valueLabel->setText(currentState == DisplayState::Secondary && ! secondaryValue.isEmpty() ? secondaryValue : primaryValue);
+ QString html = QString("").arg(4 * dpiXFactor);
+ html += QString("
%1
").arg(description);
+ if (secondaryValue.isEmpty()) {
+ html += QString("%1").arg(primaryValue);
+ setCursor(Qt::ArrowCursor);
+ } else {
+ html += QString("%1 (%2)").arg(primaryValue).arg(secondaryValue);
+ setCursor(Qt::PointingHandCursor);
+ }
+ html += "
";
+ setToolTip(html);
+}
+
+
+void
+StatisticBox::setPreferredState
+(const DisplayState &state)
+{
+ startupPreferredState = state;
+ currentPreferredState = state;
+ if (! secondaryValue.isEmpty()) {
+ currentState = state;
+ valueLabel->setText(currentState == DisplayState::Secondary ? secondaryValue : primaryValue);
+ }
+}
+
+
+void
+StatisticBox::changeEvent
+(QEvent *event)
+{
+ if (event->type() == QEvent::PaletteChange) {
+ QWidget *parent = parentWidget();
+ if (! parent) {
+ return;
+ }
+ QPalette basePalette = parent->palette();
+ QColor alt = basePalette.color(QPalette::AlternateBase);
+ QColor border = GCColor::inactiveColor(alt, 0.25);
+ QColor title = GCColor::inactiveColor(basePalette.color(QPalette::Text), 1.0);
+ QString newStyle = QString(
+ "#statbox {"
+ " background-color: %1;"
+ " border: 1px solid %2;"
+ "}"
+ "#stattitle {"
+ " color: %3;"
+ "}"
+ ).arg(alt.name())
+ .arg(border.name())
+ .arg(title.name());
+ if (styleSheet() != newStyle) {
+ setStyleSheet(newStyle);
+ }
+ }
+ QWidget::changeEvent(event);
+}
+
+
+void
+StatisticBox::mousePressEvent
+(QMouseEvent *event)
+{
+ Q_UNUSED(event);
+
+ if (secondaryValue.isEmpty()) {
+ QFrame::mousePressEvent(event);
+ return;
+ }
+
+ currentState = (currentState == DisplayState::Primary) ? DisplayState::Secondary : DisplayState::Primary;
+ valueLabel->setText(currentState == DisplayState::Secondary ? secondaryValue : primaryValue);
+ currentPreferredState = currentState;
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// JointLineTree
+
+JointLineTree::JointLineTree
+(QWidget *parent)
+: QTreeWidget(parent)
+{
+ setSelectionMode(QAbstractItemView::NoSelection);
+ setEditTriggers(QAbstractItemView::NoEditTriggers);
+ setAlternatingRowColors(false);
+ setUniformRowHeights(false);
+ setItemsExpandable(false);
+ setRootIsDecorated(false);
+ setIndentation(0);
+ setFocusPolicy(Qt::NoFocus);
+ setFrameShape(QFrame::NoFrame);
+ header()->setVisible(false);
+ setMouseTracking(true);
+ viewport()->setMouseTracking(true);
+}
+
+
+bool
+JointLineTree::isRowHovered
+(const QModelIndex &index) const
+{
+ return ( hoveredIndex.isValid()
+ && hoveredIndex.model() == index.model()
+ && hoveredIndex == index.siblingAtColumn(0))
+ || ( contextMenuIndex.isValid()
+ && contextMenuIndex.model() == index.model()
+ && contextMenuIndex == index.siblingAtColumn(0));
+}
+
+
+QColor
+JointLineTree::getHoverBG
+() const
+{
+ QColor bgColor = palette().color(QPalette::Base);
+ QColor hoverBG = palette().color(QPalette::Active, QPalette::Highlight);
+ hoverBG.setAlphaF(0.2f);
+ return GCColor::blendedColor(hoverBG, bgColor);
+}
+
+
+QColor
+JointLineTree::getHoverHL
+() const
+{
+ QColor bgColor = palette().color(QPalette::Base);
+ QColor hoverHL = palette().color(QPalette::Active, QPalette::Highlight);
+ hoverHL.setAlphaF(0.6f);
+ return GCColor::blendedColor(hoverHL, bgColor);
+}
+
+
+void
+JointLineTree::mouseMoveEvent
+(QMouseEvent *event)
+{
+ if (! contextMenuIndex.isValid()) {
+ updateHoveredIndex(event->pos());
+ }
+ QTreeWidget::mouseMoveEvent(event);
+}
+
+
+void
+JointLineTree::wheelEvent
+(QWheelEvent *event)
+{
+ if (! contextMenuIndex.isValid()) {
+ updateHoveredIndex(event->position().toPoint());
+ }
+ QTreeWidget::wheelEvent(event);
+}
+
+
+void
+JointLineTree::leaveEvent
+(QEvent *event)
+{
+ if (! contextMenuIndex.isValid()) {
+ clearHover();
+ }
+ QTreeWidget::leaveEvent(event);
+}
+
+
+void
+JointLineTree::enterEvent
+(QEnterEvent *event)
+{
+ if (! contextMenuIndex.isValid()) {
+ QPointer self(this);
+ QTimer::singleShot(0, this, [self]() {
+ if (! self) {
+ return;
+ }
+ QPoint pos = self->viewport()->mapFromGlobal(QCursor::pos());
+ if (self->viewport()->rect().contains(pos)) {
+ self->updateHoveredIndex(pos);
+ sendFakeHoverMove(self->viewport(), pos);
+ }
+ });
+ }
+ QTreeWidget::enterEvent(event);
+}
+
+
+void
+JointLineTree::contextMenuEvent
+(QContextMenuEvent *event)
+{
+ QModelIndex index = indexAt(event->pos());
+ if (! index.isValid()) {
+ return;
+ }
+
+ index = index.siblingAtColumn(0);
+ QMenu *contextMenu = createContextMenu(index);
+ if (contextMenu == nullptr) {
+ return;
+ }
+
+ contextMenuIndex = QPersistentModelIndex(index);
+ hoveredIndex = contextMenuIndex;
+
+ QRect rowRect = visualRect(index);
+ rowRect.setLeft(0);
+ rowRect.setRight(viewport()->width());
+ viewport()->update(rowRect);
+
+ contextMenu->exec(event->globalPos());
+
+ delete contextMenu;
+
+ QPersistentModelIndex oldContextIndex = contextMenuIndex;
+ contextMenuIndex = QPersistentModelIndex();
+
+ QPoint currentPos = viewport()->mapFromGlobal(QCursor::pos());
+ if (viewport()->rect().contains(currentPos)) {
+ updateHoveredIndex(currentPos);
+ sendFakeHoverMove(viewport(), currentPos);
+ } else {
+ if (oldContextIndex.isValid()) {
+ QRect oldRowRect = visualRect(oldContextIndex);
+ oldRowRect.setLeft(0);
+ oldRowRect.setRight(viewport()->width());
+ viewport()->update(oldRowRect);
+ }
+ hoveredIndex = QPersistentModelIndex();
+ sendFakeHoverLeave(viewport());
+ }
+}
+
+
+void
+JointLineTree::changeEvent
+(QEvent *event)
+{
+ if (event->type() == QEvent::ActivationChange) {
+ if (! isActiveWindow()) {
+ if (! contextMenuIndex.isValid()) {
+ clearHover();
+ sendFakeHoverLeave(viewport());
+ }
+ } else {
+ QPointer self(this);
+ QTimer::singleShot(0, this, [self]() {
+ if (! self) {
+ return;
+ }
+ QPoint pos = self->viewport()->mapFromGlobal(QCursor::pos());
+ if (self->viewport()->rect().contains(pos)) {
+ self->updateHoveredIndex(pos);
+ sendFakeHoverMove(self->viewport(), pos);
+ }
+ });
+ }
+ } else if (event->type() == QEvent::PaletteChange) {
+ clearHover();
+ QPointer self(this);
+ QTimer::singleShot(0, this, [self]() {
+ if (! self) {
+ return;
+ }
+ QPalette treePal = self->palette();
+ treePal.setColor(QPalette::Base, treePal.color(QPalette::Base));
+ self->setPalette(treePal);
+ self->viewport()->setPalette(treePal);
+ self->setAutoFillBackground(false);
+ });
+ }
+
+ QWidget::changeEvent(event);
+}
+
+
+bool
+JointLineTree::viewportEvent
+(QEvent *event)
+{
+ switch (event->type()) {
+ case QEvent::HoverEnter:
+ case QEvent::HoverMove:
+ if (! contextMenuIndex.isValid()) {
+ QHoverEvent *he = static_cast(event);
+ updateHoveredIndex(he->position().toPoint());
+ }
+ break;
+ case QEvent::HoverLeave:
+ case QEvent::Leave: {
+ if (contextMenuIndex.isValid()) {
+ return true;
+ }
+ bool result = QTreeWidget::viewportEvent(event);
+ clearHover();
+ return result;
+ }
+ case QEvent::ToolTip: {
+ QHelpEvent *helpEvent = static_cast(event);
+ QModelIndex hoverIndex = indexAt(helpEvent->pos());
+ if (! hoverIndex.isValid()) {
+ return QTreeWidget::viewportEvent(event);
+ }
+ QString toolTipText = createToolTipText(hoverIndex);
+ if (! toolTipText.isEmpty()) {
+ QToolTip::showText(helpEvent->globalPos(), toolTipText, this);
+ return true;
+ }
+ QToolTip::hideText();
+ event->ignore();
+ return true;
+ }
+ default:
+ break;
+ }
+ return QTreeWidget::viewportEvent(event);
+}
+
+
+void
+JointLineTree::drawRow
+(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ bool isHovered = isRowHovered(index);
+ QStyleOptionViewItem opt = option;
+ if (isHovered) {
+ painter->save();
+
+ QRect hoverRect(option.rect);
+ hoverRect.setX(hoverRect.x() + 4 * dpiXFactor);
+ painter->fillRect(hoverRect, getHoverBG());
+ QRect markerRect(option.rect);
+ markerRect.setWidth(4 * dpiXFactor);
+ painter->fillRect(markerRect, getHoverHL());
+
+ painter->restore();
+ opt.state |= QStyle::State_MouseOver;
+ }
+ QTreeWidget::drawRow(painter, opt, index);
+}
+
+
+void
+JointLineTree::clearHover
+()
+{
+ if (hoveredIndex.isValid()) {
+ QRect oldRect = visualRect(hoveredIndex);
+ oldRect.setLeft(0);
+ oldRect.setRight(viewport()->width());
+ viewport()->update(oldRect);
+ QToolTip::hideText();
+
+ hoveredIndex = QPersistentModelIndex();
+ }
+}
+
+
+void
+JointLineTree::updateHoveredIndex
+(const QPoint &pos)
+{
+ QModelIndex index = indexAt(pos);
+
+ if (index.isValid()) {
+ index = index.siblingAtColumn(0);
+ }
+
+ if (index != hoveredIndex) {
+ QToolTip::hideText();
+ QPersistentModelIndex oldIndex = hoveredIndex;
+ hoveredIndex = QPersistentModelIndex(index);
+
+ if (oldIndex.isValid()) {
+ QRect oldRect = visualRect(oldIndex);
+ oldRect.setLeft(0);
+ oldRect.setRight(viewport()->width());
+ viewport()->update(oldRect);
+ }
+
+ if (hoveredIndex.isValid()) {
+ QRect newRect = visualRect(hoveredIndex);
+ newRect.setLeft(0);
+ newRect.setRight(viewport()->width());
+ viewport()->update(newRect);
+ }
+ }
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceOffsetHandler
+
+void
+PlanAdherenceOffsetHandler::setMinAllowedOffset
+(int minOffset)
+{
+ minAllowedOffset = minOffset;
+}
+
+
+void
+PlanAdherenceOffsetHandler::setMaxAllowedOffset
+(int maxOffset)
+{
+ maxAllowedOffset = maxOffset;
+}
+
+
+void
+PlanAdherenceOffsetHandler::setMinEntryOffset
+(int minOffset)
+{
+ minEntryOffset = minOffset;
+}
+
+
+void
+PlanAdherenceOffsetHandler::setMaxEntryOffset
+(int maxOffset)
+{
+ maxEntryOffset = maxOffset;
+}
+
+
+bool
+PlanAdherenceOffsetHandler::hasMinOverflow
+() const
+{
+ return minEntryOffset < minAllowedOffset;
+}
+
+
+bool
+PlanAdherenceOffsetHandler::hasMaxOverflow
+() const
+{
+ return maxEntryOffset > maxAllowedOffset;
+}
+
+
+int
+PlanAdherenceOffsetHandler::minVisibleOffset
+() const
+{
+ return std::max(minEntryOffset, minAllowedOffset);
+}
+
+
+int
+PlanAdherenceOffsetHandler::maxVisibleOffset
+() const
+{
+ return std::min(maxEntryOffset, maxAllowedOffset);
+}
+
+
+bool
+PlanAdherenceOffsetHandler::isMinOverflow
+(int offset) const
+{
+ return offset < minAllowedOffset;
+}
+
+
+bool
+PlanAdherenceOffsetHandler::isMaxOverflow
+(int offset) const
+{
+ return offset > maxAllowedOffset;
+}
+
+
+int
+PlanAdherenceOffsetHandler::indexFromOffset
+(int offset) const
+{
+ if (isMaxOverflow(offset)) {
+ return numCells() - 1;
+ } else if (isMinOverflow(offset)) {
+ return 0;
+ }
+ int index = offset - minVisibleOffset();
+ if (hasMinOverflow()) {
+ ++index;
+ }
+ return index;
+}
+
+
+int
+PlanAdherenceOffsetHandler::numCells
+() const
+{
+ int cellCount = maxVisibleOffset() - minVisibleOffset() + 1;
+ if (hasMinOverflow()) {
+ ++cellCount;
+ }
+ if (hasMaxOverflow()) {
+ ++cellCount;
+ }
+ return cellCount;
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceTitleDelegate
+
+PlanAdherenceTitleDelegate::PlanAdherenceTitleDelegate
+(QObject *parent)
+: QStyledItemDelegate(parent)
+{
+}
+
+
+void
+PlanAdherenceTitleDelegate::paint
+(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ PlanAdherenceTree const * const tree = qobject_cast(opt.widget);
+ if (tree == nullptr) {
+ return;
+ }
+
+ PlanAdherenceEntry entry = index.siblingAtColumn(0).data(PlanAdherence::DataRole).value();
+ QColor textColor = opt.palette.color(QPalette::Active, QPalette::Text);
+ QColor iconColor = entry.color;
+ if (opt.state & QStyle::State_MouseOver) {
+ iconColor = GCColor::invertColor(tree->getHoverBG());
+ textColor = iconColor;
+ }
+
+ QFont titleFont = painter->font();
+ titleFont.setPointSizeF(titleFont.pointSizeF() * 0.9);
+ QFontMetrics titleFM(titleFont);
+ QFont dateFont = titleFont;
+ dateFont.setWeight(QFont::ExtraLight);
+
+ const int lineHeight = titleFM.height();
+ const int iconWidth = 2 * lineHeight + lineSpacing;
+ const QSize pixmapSize(iconWidth, iconWidth);
+ const int paddingTop = (opt.rect.height() - 2 * lineHeight - lineSpacing) / 2;
+ const int iconX = opt.rect.x() + leftPadding;
+ const int textX = iconX + iconWidth + iconTextSpacing;
+ const int line1Y = opt.rect.top() + paddingTop;
+ const int maxTextWidth = opt.rect.width() - (leftPadding + iconWidth + iconTextSpacing);
+
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing, true);
+
+ painter->setPen(textColor);
+
+ QPixmap pixmap = svgAsColoredPixmap(entry.iconFile, pixmapSize, iconInnerSpacing, iconColor);
+ painter->drawPixmap(iconX, line1Y, pixmap);
+ painter->setFont(titleFont);
+ QRect textRect(textX, line1Y, maxTextWidth, lineHeight);
+ painter->drawText(textRect, Qt::AlignLeft, titleFM.elidedText(entry.titlePrimary, Qt::ElideRight, textRect.width()));
+ textRect.translate(0, lineHeight + lineSpacing);
+ painter->setFont(dateFont);
+ QLocale locale;
+ QString dateString = locale.toString(entry.date, tr("ddd dd.MM."));
+ if (entry.shiftOffset != std::nullopt && entry.shiftOffset.value() != 0) {
+ QDate shiftedDate = entry.date.addDays(entry.shiftOffset.value());
+ dateString += " → " + locale.toString(shiftedDate, tr("dd.MM."));
+ }
+ painter->drawText(textRect, Qt::AlignLeft, dateString);
+
+ painter->restore();
+}
+
+
+QSize
+PlanAdherenceTitleDelegate::sizeHint
+(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ PlanAdherenceEntry entry = index.siblingAtColumn(0).data(PlanAdherence::DataRole).value();
+
+ QFont font = option.font;
+ font.setPointSizeF(font.pointSizeF() * 0.9);
+ const QFontMetrics fm(font);
+ const int lineHeight = fm.height();
+ const int iconWidth = 2 * lineHeight + lineSpacing;
+ const int titleLength = fm.horizontalAdvance(entry.titlePrimary) * 1.15;
+ QLocale locale;
+ QString dateString = locale.toString(entry.date, tr("ddd dd.MM."));
+ if (entry.shiftOffset != std::nullopt && entry.shiftOffset.value() != 0) {
+ QDate shiftedDate = entry.date.addDays(entry.shiftOffset.value());
+ dateString += " → " + locale.toString(shiftedDate, tr("dd.MM."));
+ }
+ const int dateLength = fm.horizontalAdvance(dateString) * 1.15;
+ int width = leftPadding + iconWidth + std::max(titleLength, dateLength);
+
+ return QSize(std::min(width, maxWidth), 2 * (vertMargin + lineHeight) + lineSpacing);
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceOffsetDelegate
+
+PlanAdherenceOffsetDelegate::PlanAdherenceOffsetDelegate
+(QObject *parent)
+: QStyledItemDelegate(parent), PlanAdherenceOffsetHandler()
+{
+}
+
+
+void
+PlanAdherenceOffsetDelegate::paint
+(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ int cellCount = numCells();
+ if (cellCount == 0) {
+ return;
+ }
+
+ QColor bgColor = option.palette.color(QPalette::Base);
+
+ PlanAdherenceEntry entry = index.siblingAtColumn(0).data(PlanAdherence::DataRole).value();
+ int shiftOffset = entry.shiftOffset != std::nullopt ? entry.shiftOffset.value() : 0;
+ int minEntryOffset = std::min(0, shiftOffset);
+ int maxEntryOffset = std::max(0, shiftOffset);
+ bool hasActual = entry.actualOffset != std::nullopt;
+ int actualOffset;
+ if (hasActual) {
+ actualOffset = entry.actualOffset.value();
+ minEntryOffset = std::min(minEntryOffset, actualOffset);
+ maxEntryOffset = std::max(maxEntryOffset, actualOffset);
+ }
+ const int availableHeight = option.rect.height() - 2 * vertMargin;
+ const int availableWidth = option.rect.width() - 2 * horMargin;
+ const int availableLeft = option.rect.left() + horMargin;
+ const float cellWidth = availableWidth / cellCount;
+ const float cellHorCenter = cellWidth / 2.0;
+ const int radius = availableHeight / 4 - 2 * lineSpacing;
+ const int vertCenter = option.rect.top() + option.rect.height() / 2;
+ const int vertTop = vertCenter - availableHeight / 4;
+ const int vertBottom = vertCenter + availableHeight / 4;
+
+ int day0X = (indexFromOffset(0) + 1) * cellWidth - cellHorCenter + availableLeft;
+ int shiftX = (indexFromOffset(shiftOffset) + 1) * cellWidth - cellHorCenter + availableLeft;
+ int actualX = day0X;
+ if (hasActual) {
+ actualX = (indexFromOffset(actualOffset) + 1) * cellWidth - cellHorCenter + availableLeft;
+ }
+ int day0Y = vertCenter;
+ int shiftY = vertCenter;
+ int actualY = vertCenter;
+ if (hasActual) {
+ if (actualOffset == 0) {
+ day0Y = vertTop;
+ actualY = vertBottom;
+ } else if ( actualOffset == shiftOffset
+ || ( actualOffset < minAllowedOffset
+ && shiftOffset < minAllowedOffset)
+ || ( actualOffset > maxAllowedOffset
+ && shiftOffset > maxAllowedOffset)) {
+ shiftY = vertTop;
+ actualY = vertBottom;
+ }
+ }
+
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing, true);
+
+ if (! (option.state & QStyle::State_MouseOver)) {
+ QColor overflowbg(option.palette.color(QPalette::Active, QPalette::AlternateBase));
+ if (hasMinOverflow()) {
+ painter->fillRect(QRect(availableLeft, option.rect.top(), cellWidth, option.rect.height()), overflowbg);
+ }
+ if (hasMaxOverflow()) {
+ painter->fillRect(QRect(availableLeft + (cellCount - 1) * cellWidth, option.rect.top(), cellWidth, option.rect.height()), overflowbg);
+ }
+ }
+
+ painter->save();
+ QColor day0LineFg(option.palette.color(QPalette::Active, QPalette::Text));
+ day0LineFg.setAlpha(20);
+ day0LineFg = GCColor::blendedColor(day0LineFg, bgColor);
+ QPen day0LinePen(day0LineFg);
+ day0LinePen.setWidth(radius / 3);
+ painter->setPen(day0LinePen);
+ painter->drawLine(day0X, option.rect.top(), day0X, option.rect.bottom());
+ painter->restore();
+
+ if (minEntryOffset != maxEntryOffset) {
+ const int leftX = (indexFromOffset(minEntryOffset) + 1) * cellWidth - cellHorCenter + availableLeft;
+ const int rightX = (indexFromOffset(maxEntryOffset) + 1) * cellWidth - cellHorCenter + availableLeft;
+ QColor connectorColor(option.palette.color(QPalette::Disabled, QPalette::Text));
+ if (hasActual) {
+ connectorColor.setAlpha(150);
+ } else {
+ connectorColor.setAlpha(75);
+ }
+ QPen pen(GCColor::blendedColor(connectorColor, bgColor));
+ pen.setWidth(radius / 1.1);
+ pen.setCapStyle(Qt::RoundCap);
+ painter->setPen(pen);
+ painter->drawLine(QPoint(leftX + 2 * radius, vertCenter), QPoint(rightX - 2 * radius, vertCenter));
+ }
+ painter->setPen(Qt::NoPen);
+
+ if (hasActual) {
+ painter->setBrush(GCColor::getSuccessColor(option.palette));
+ painter->drawEllipse(QPoint(actualX, actualY), radius, radius);
+ }
+
+ QColor solidPlannedColor = GColor(CCALPLANNED);
+ if (shiftOffset != 0) {
+ QColor fadedPlannedColor(solidPlannedColor);
+ fadedPlannedColor.setAlpha(100);
+ fadedPlannedColor = GCColor::blendedColor(fadedPlannedColor, bgColor);
+ painter->setBrush(fadedPlannedColor);
+ painter->drawEllipse(QPoint(shiftX, shiftY), radius, radius);
+ }
+ if (entry.isPlanned) {
+ painter->setBrush(solidPlannedColor);
+ } else {
+ QColor unplannedColor(option.palette.color(QPalette::Disabled, QPalette::Text));
+ unplannedColor.setAlpha(150);
+ painter->setBrush(GCColor::blendedColor(unplannedColor, bgColor));
+ }
+ painter->drawEllipse(QPoint(day0X, day0Y), radius, radius);
+
+ painter->restore();
+}
+
+
+QSize
+PlanAdherenceOffsetDelegate::sizeHint
+(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ Q_UNUSED(index)
+
+ const QFontMetrics fontMetrics(option.font);
+ const int lineHeight = fontMetrics.height();
+
+ return QSize(0, 2 * (vertMargin + lineHeight));
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceStatusDelegate
+
+PlanAdherenceStatusDelegate::PlanAdherenceStatusDelegate
+(QObject *parent)
+: QStyledItemDelegate(parent)
+{
+}
+
+
+void
+PlanAdherenceStatusDelegate::paint
+(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ painter->save();
+
+ QFont line1Font = opt.font;
+ QFontMetrics line1FM(line1Font);
+ painter->setPen(opt.palette.color(QPalette::Text));
+
+ QString line1 = index.data(Line1Role).toString();
+ QString line2 = index.data(Line2Role).toString();
+ if (line2.isEmpty()) {
+ QRect lineRect = option.rect.adjusted(horMargin, vertMargin, -(horMargin + rightPadding), -vertMargin);
+ painter->setFont(line1Font);
+ painter->drawText(lineRect, Qt::AlignRight | Qt::AlignVCenter, line1);
+ } else {
+ QFont::Weight line2Weight = static_cast(index.data(Line2WeightRole).toInt());
+ QFont line2Font = line1Font;
+ line2Font.setWeight(line2Weight);
+ QFontMetrics line2FM(line2Font);
+ int line1Height = line1FM.height();
+ int line2Height = line2FM.height();
+ int extraHeight = (option.rect.height() - 2 * vertMargin - line1Height - lineSpacing - line2Height) / 2;
+ QRect lineRect = option.rect.adjusted(horMargin, vertMargin + extraHeight, -(horMargin + rightPadding), 0);
+ lineRect.setHeight(line1Height);
+ painter->setFont(line1Font);
+ painter->drawText(lineRect, Qt::AlignRight | Qt::AlignVCenter, line1);
+ lineRect.moveTop(lineRect.top() + line1Height + lineSpacing);
+ painter->setFont(line2Font);
+ painter->drawText(lineRect, Qt::AlignRight | Qt::AlignVCenter, line2);
+ }
+ painter->restore();
+}
+
+
+QSize
+PlanAdherenceStatusDelegate::sizeHint
+(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+ QString line1 = index.data(Line1Role).toString();
+ QString line2 = index.data(Line2Role).toString();
+ QFont line1Font = option.font;
+ QFontMetrics line1FM(line1Font);
+ int width = line1FM.horizontalAdvance(line1);
+ int height = line1FM.height();
+ if (! line2.isEmpty()) {
+ QFont::Weight line2Weight = static_cast(index.data(Line2WeightRole).toInt());
+ QFont line2Font = line1Font;
+ line2Font.setWeight(line2Weight);
+ QFontMetrics line2FM(line2Font);
+ int widthLine2 = line2FM.horizontalAdvance(line2);
+ int heightLine2 = line2FM.height();
+ width = std::max(width, widthLine2);
+ height += lineSpacing + heightLine2;
+ }
+ width += 2 * horMargin + rightPadding;
+ height += 2 * vertMargin;
+ return QSize(width, height);
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceHeaderView
+
+PlanAdherenceHeaderView::PlanAdherenceHeaderView
+(Qt::Orientation orientation, QWidget *parent)
+: QHeaderView(orientation, parent), PlanAdherenceOffsetHandler()
+{
+}
+
+
+void
+PlanAdherenceHeaderView::paintSection
+(QPainter *painter, const QRect &rect, int logicalIndex) const
+{
+ QStyleOptionHeader opt;
+ initStyleOption(&opt);
+ opt.rect = rect;
+ opt.section = logicalIndex;
+
+ if (logicalIndex == 1) {
+ style()->drawControl(QStyle::CE_HeaderSection, &opt, painter, this);
+ int cellCount = numCells();
+ if (cellCount == 0) {
+ return;
+ }
+
+ const int availableWidth = rect.width() - 2 * horMargin;
+ const int availableLeft = rect.left() + horMargin;
+ const float cellWidth = availableWidth / cellCount;
+ painter->save();
+ QRect cellRect(availableLeft, rect.top(), availableWidth, rect.height());
+ bool addMinOverflow = hasMinOverflow();
+ bool addMaxOverflow = hasMaxOverflow();
+ int offsetShift = addMinOverflow ? 1 : 0;
+ for (int i = 0; i < cellCount; ++i) {
+ QString label;
+ if ( (i == 0 && addMinOverflow)
+ || (i == cellCount - 1 && addMaxOverflow)) {
+ label = "…";
+ } else {
+ int offset = i + minVisibleOffset() - offsetShift;
+ if (offset == 0) {
+ label = "0";
+ } else {
+ label = QString::asprintf("%+d", offset);
+ }
+ label += tr("d");
+ }
+ QRect labelRect(availableLeft + i * cellWidth, rect.top(), cellWidth - 4, rect.height());
+ painter->drawText(labelRect, Qt::AlignCenter, label);
+ }
+ painter->restore();
+ } else {
+ opt.text = model()->headerData(logicalIndex, orientation(), Qt::DisplayRole).toString();
+ style()->drawControl(QStyle::CE_Header, &opt, painter, this);
+ }
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceTree
+
+PlanAdherenceTree::PlanAdherenceTree
+(QWidget *parent)
+: JointLineTree(parent)
+{
+ headerView = new PlanAdherenceHeaderView(Qt::Horizontal, this);
+ setHeader(headerView);
+ header()->setStyleSheet("QHeaderView::section {"
+ " border-left: none;"
+ " border-right: none;"
+ " border-top: none;"
+ " border-bottom: 1px solid palette(mid);"
+ "}");
+
+ setColumnCount(3);
+ header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ header()->setSectionResizeMode(1, QHeaderView::Stretch);
+ header()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
+ header()->setStretchLastSection(false);
+ headerItem()->setText(0, "");
+ headerItem()->setText(2, "");
+
+ setItemDelegateForColumn(0, new PlanAdherenceTitleDelegate());
+ planAdherenceOffsetDelegate = new PlanAdherenceOffsetDelegate();
+ setItemDelegateForColumn(1, planAdherenceOffsetDelegate);
+ setItemDelegateForColumn(2, new PlanAdherenceStatusDelegate());
+}
+
+
+void
+PlanAdherenceTree::fillEntries
+(const QList &entries, const PlanAdherenceOffsetRange &offsets)
+{
+ clear();
+ QLocale locale;
+ for (const PlanAdherenceEntry &entry : entries) {
+ QString statusLine1;
+ QString statusLine2;
+ QFont::Weight statusLine2Weight = QFont::Normal;
+ if (! entry.isPlanned) {
+ statusLine1 = tr("Unplanned");
+ } else if (entry.actualOffset != std::nullopt) {
+ statusLine1 = tr("Completed");
+ if (entry.actualOffset.value() != 0) {
+ statusLine2 = QString::asprintf("%+lld", entry.actualOffset.value()) + tr("d");
+ statusLine2Weight = QFont::Bold;
+ } else {
+ statusLine2 = tr("On-Time");
+ statusLine2Weight = QFont::ExtraLight;
+ }
+ } else if (entry.date >= QDate::currentDate()) {
+ statusLine1 = tr("Upcoming");
+ } else if (entry.shiftOffset != std::nullopt && entry.date.addDays(entry.shiftOffset.value()) >= QDate::currentDate()) {
+ statusLine1 = tr("Shifted");
+ statusLine2 = tr("Upcoming");
+ statusLine2Weight = QFont::ExtraLight;
+ } else {
+ statusLine1 = tr("Missed");
+ }
+
+ QTreeWidgetItem *entryItem = new QTreeWidgetItem(this);
+ entryItem->setData(0, PlanAdherence::DataRole, QVariant::fromValue(entry));
+ entryItem->setData(2, PlanAdherenceStatusDelegate::Line1Role, statusLine1);
+ entryItem->setData(2, PlanAdherenceStatusDelegate::Line2Role, statusLine2);
+ entryItem->setData(2, PlanAdherenceStatusDelegate::Line2WeightRole, statusLine2Weight);
+ }
+ planAdherenceOffsetDelegate->setMinEntryOffset(offsets.min);
+ planAdherenceOffsetDelegate->setMaxEntryOffset(offsets.max);
+ planAdherenceOffsetDelegate->setMinAllowedOffset(minAllowedOffset);
+ planAdherenceOffsetDelegate->setMaxAllowedOffset(maxAllowedOffset);
+
+ headerView->setMinEntryOffset(offsets.min);
+ headerView->setMaxEntryOffset(offsets.max);
+ headerView->setMinAllowedOffset(minAllowedOffset);
+ headerView->setMaxAllowedOffset(maxAllowedOffset);
+}
+
+
+QColor
+PlanAdherenceTree::getHoverBG
+() const
+{
+ QColor bgColor = palette().color(QPalette::Base);
+ QColor hoverBG = palette().color(QPalette::Active, QPalette::Highlight);
+ hoverBG.setAlphaF(0.2f);
+ return GCColor::blendedColor(hoverBG, bgColor);
+}
+
+
+QColor
+PlanAdherenceTree::getHoverHL
+() const
+{
+ QColor bgColor = palette().color(QPalette::Base);
+ QColor hoverHL = palette().color(QPalette::Active, QPalette::Highlight);
+ hoverHL.setAlphaF(0.6f);
+ return GCColor::blendedColor(hoverHL, bgColor);
+}
+
+
+void
+PlanAdherenceTree::setMinAllowedOffset
+(int offset)
+{
+ minAllowedOffset = offset;
+}
+
+
+void
+PlanAdherenceTree::setMaxAllowedOffset
+(int offset)
+{
+ maxAllowedOffset = offset;
+}
+
+
+QMenu*
+PlanAdherenceTree::createContextMenu
+(const QModelIndex &index)
+{
+ const QString ellipsis = QStringLiteral("...");
+ PlanAdherenceEntry entry = index.siblingAtColumn(0).data(PlanAdherence::DataRole).value();
+ QMenu *contextMenu = new QMenu(this);
+ if (! entry.plannedReference.isEmpty()) {
+ contextMenu->addAction(tr("View planned activity") % ellipsis, this, [this, entry]() { emit viewActivity(entry.plannedReference, true); });
+ }
+ if (! entry.actualReference.isEmpty()) {
+ contextMenu->addAction(tr("View actual activity") % ellipsis, this, [this, entry]() { emit viewActivity(entry.actualReference, false); });
+ }
+ return contextMenu;
+}
+
+
+QString
+PlanAdherenceTree::createToolTipText
+(const QModelIndex &index) const
+{
+ PlanAdherenceEntry entry = index.siblingAtColumn(0).data(PlanAdherence::DataRole).value();
+ QString html = QString("").arg(4 * dpiXFactor);
+ html += QString("
%1
")
+ .arg(entry.titlePrimary);
+ html += "
";
+ QString status;
+ QString dateLabel;
+ bool hasActual = false;
+ int actualOffset = 0;
+ bool isShifted = false;
+ int shiftOffset = 0;
+ if (! entry.isPlanned) {
+ status = tr("Unplanned");
+ dateLabel = tr("Date");
+ } else {
+ hasActual = entry.actualOffset != std::nullopt;
+ if (hasActual) {
+ actualOffset = entry.actualOffset.value();
+ }
+ isShifted = entry.shiftOffset != std::nullopt;
+ if (isShifted) {
+ shiftOffset = entry.shiftOffset.value();
+ }
+ if (isShifted) {
+ dateLabel = tr("Originally Planned");
+ } else {
+ dateLabel = tr("Planned");
+ }
+ if (hasActual) {
+ status = tr("Completed");
+ } else if (entry.date.addDays(shiftOffset) >= QDate::currentDate()) {
+ status = tr("Upcoming");
+ } else {
+ status = tr("Missed");
+ }
+ }
+ QLocale locale;
+ html += QString("| %1: | %2 |
").arg(tr("Status")).arg(status);
+ html += QString("| %1: | %2 |
")
+ .arg(dateLabel)
+ .arg(locale.toString(entry.date, QLocale::NarrowFormat));
+ if (isShifted) {
+ QString shiftDateStr = locale.toString(entry.date.addDays(shiftOffset), QLocale::NarrowFormat);
+ QString shiftValue;
+ if (shiftOffset < -1) {
+ shiftValue = tr("%1 days earlier (→ %2)").arg(-shiftOffset).arg(shiftDateStr);
+ } else if (shiftOffset == -1) {
+ shiftValue = tr("1 day earlier (→ %1)").arg(shiftDateStr);
+ } else if (shiftOffset == 1) {
+ shiftValue = tr("1 day later (→ %1)").arg(shiftDateStr);
+ } else {
+ shiftValue = tr("%1 days later (→ %2)").arg(shiftOffset).arg(shiftDateStr);
+ }
+ html += QString("| %1: | %2 |
")
+ .arg(tr("Shifted"))
+ .arg(shiftValue);
+ }
+ if (hasActual) {
+ QString actualDateStr = locale.toString(entry.date.addDays(actualOffset), QLocale::NarrowFormat);
+ QString actualValue;
+ if (actualOffset < -1) {
+ actualValue = tr("%1 days early (%2)").arg(-actualOffset).arg(actualDateStr);
+ } else if (actualOffset == -1) {
+ actualValue = tr("1 day early (%1)").arg(actualDateStr);
+ } else if (actualOffset == 1) {
+ actualValue = tr("1 day late (%1)").arg(actualDateStr);
+ } else if (actualOffset > 1) {
+ actualValue = tr("%1 days late (%2)").arg(actualOffset).arg(actualDateStr);
+ } else {
+ actualValue = tr("On-Time");
+ }
+ html += QString("| %1: | %2 |
")
+ .arg(tr("Completed"))
+ .arg(actualValue);
+ html += QString("| %1: | %2 |
")
+ .arg(tr("Completed as"))
+ .arg(entry.titleSecondary);
+ }
+ html += "
";
+ return html;
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// PlanAdherenceView
+
+PlanAdherenceView::PlanAdherenceView
+(QWidget *parent)
+: QWidget(parent)
+{
+ qRegisterMetaType("PlanAdherenceEntry");
+
+ toolbar = new QToolBar();
+
+ prevAction = toolbar->addAction(tr("Previous Month"));
+ nextAction = toolbar->addAction(tr("Next Month"));
+ todayAction = toolbar->addAction(tr("Today"));
+ separator = toolbar->addSeparator();
+
+ dateNavigator = new QToolButton();
+ dateNavigator->setPopupMode(QToolButton::InstantPopup);
+ dateNavigatorAction = toolbar->addWidget(dateNavigator);
+
+ dateMenu = new QMenu(this);
+ connect(dateMenu, &QMenu::aboutToShow, this, &PlanAdherenceView::populateDateMenu);
+
+ dateNavigator->setMenu(dateMenu);
+
+ QWidget *spacer = new QWidget(toolbar);
+ spacer->setFixedWidth(10 * dpiXFactor);
+ spacer->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
+ toolbar->addWidget(spacer);
+
+ QLabel *filterLabel = new QLabel("" + tr("Filters applied") + "");
+ filterLabelAction = toolbar->addWidget(filterLabel);
+
+ QWidget *stretch = new QWidget();
+ stretch->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ toolbar->addWidget(stretch);
+
+ timeline = new PlanAdherenceTree();
+
+ totalBox = new StatisticBox(tr("Total"), tr("Total number of activities in this period"));
+ plannedBox = new StatisticBox(tr("Planned"), tr("Activities that were part of the training plan"));
+ unplannedBox = new StatisticBox(tr("Unplanned"), tr("Activities completed without being in the plan"));
+ onTimeBox = new StatisticBox(tr("On-Time"), tr("Planned activities completed on the scheduled date"));
+ shiftedBox = new StatisticBox(tr("Shifted"), tr("Planned activities rescheduled to a different date"));
+ missedBox = new StatisticBox(tr("Missed"), tr("Planned activities that were not completed"));
+ avgShiftBox = new StatisticBox(tr("Avg Shift"), tr("Average number of days between original and rescheduled planned date"));
+ totalShiftDaysBox = new StatisticBox(tr("Total Shift Days"), tr("Sum of days by which planned activities were rescheduled"));
+
+ QHBoxLayout *statisticsLayout = new QHBoxLayout();
+ statisticsLayout->addWidget(totalBox);
+ statisticsLayout->addWidget(plannedBox);
+ statisticsLayout->addWidget(unplannedBox);
+ statisticsLayout->addWidget(onTimeBox);
+ statisticsLayout->addWidget(shiftedBox);
+ statisticsLayout->addWidget(missedBox);
+ statisticsLayout->addWidget(avgShiftBox);
+ statisticsLayout->addWidget(totalShiftDaysBox);
+
+ QWidget *adherenceWidget = new QWidget();
+ QVBoxLayout *adherenceLayout = new QVBoxLayout(adherenceWidget);
+ adherenceLayout->addLayout(statisticsLayout);
+ adherenceLayout->addSpacing(15 * dpiYFactor);
+ adherenceLayout->addWidget(timeline);
+
+ QString emptyHeadline = tr("No activities found");
+ QString emptyInfoline = tr("There are no activities for the selected month.");
+ QString emptyActionline = tr("Try selecting a different month, adjusting your filters, or creating a new activity.");
+ QString emptyMsg = QString("%1
%2
%3").arg(emptyHeadline).arg(emptyInfoline).arg(emptyActionline);
+ QLabel *emptyLabel = new QLabel(emptyMsg);
+ emptyLabel->setAlignment(Qt::AlignCenter);
+ emptyLabel->setWordWrap(true);
+
+ viewStack = new QStackedWidget();
+ viewStack->addWidget(adherenceWidget);
+ viewStack->addWidget(emptyLabel);
+
+ QVBoxLayout *layout = new QVBoxLayout(this);
+ layout->addWidget(toolbar);
+ layout->addWidget(viewStack);
+ layout->addSpacing(16 * dpiYFactor);
+
+ connect(timeline, &PlanAdherenceTree::viewActivity, this, &PlanAdherenceView::viewActivity);
+ connect(prevAction, &QAction::triggered, this, [this]() {
+ dateInMonth = QDate(dateInMonth.year(), dateInMonth.month(), 1).addMonths(-1);
+ emit monthChanged(dateInMonth);
+ });
+ connect(nextAction, &QAction::triggered, this, [this]() {
+ dateInMonth = QDate(dateInMonth.year(), dateInMonth.month(), 1).addMonths(1);
+ emit monthChanged(dateInMonth);
+ });
+ connect(todayAction, &QAction::triggered, this, [this]() {
+ dateInMonth = QDate(QDate::currentDate().year(), QDate::currentDate().month(), 1);
+ emit monthChanged(dateInMonth);
+ });
+ connect(this, &PlanAdherenceView::monthChanged, this, [this]() {
+ updateHeader();
+ setNavButtonState();
+ });
+ applyNavIcons();
+}
+
+
+QDate
+PlanAdherenceView::firstVisibleDay
+() const
+{
+ if (! dateInMonth.isValid()) {
+ return dateRange.from;
+ }
+ QDate firstOfMonth(dateInMonth.year(), dateInMonth.month(), 1);
+ if (dateRange.pass(firstOfMonth)) {
+ return firstOfMonth;
+ } else {
+ return dateRange.from;
+ }
+}
+
+
+QDate
+PlanAdherenceView::lastVisibleDay
+() const
+{
+ if (! dateInMonth.isValid()) {
+ return dateRange.to;
+ }
+ QDate lastOfMonth(dateInMonth.year(), dateInMonth.month(), dateInMonth.daysInMonth());
+ if (dateRange.pass(lastOfMonth)) {
+ return lastOfMonth;
+ } else {
+ return dateRange.to;
+ }
+}
+
+
+void
+PlanAdherenceView::fillEntries
+(const QList &entries, const PlanAdherenceStatistics &statistics, const PlanAdherenceOffsetRange &offsets, bool isFiltered)
+{
+ filterLabelAction->setVisible(isFiltered);
+ timeline->fillEntries(entries, offsets);
+ QLocale locale;
+ totalBox->setValue(locale.toString(statistics.totalAbs));
+ if (statistics.totalAbs == 0) {
+ plannedBox->setValue(locale.toString(statistics.plannedAbs), tr("N/A"));
+ unplannedBox->setValue(locale.toString(statistics.unplannedAbs), tr("N/A"));
+ } else {
+ plannedBox->setValue(locale.toString(statistics.plannedAbs), locale.toString(statistics.plannedRel, 'g', 3) + "%");
+ unplannedBox->setValue(locale.toString(statistics.unplannedAbs), locale.toString(statistics.unplannedRel, 'g', 3) + "%");
+ }
+ if (statistics.plannedAbs == 0) {
+ onTimeBox->setValue(locale.toString(statistics.onTimeAbs), tr("N/A"));
+ shiftedBox->setValue(locale.toString(statistics.shiftedAbs), tr("N/A"));
+ missedBox->setValue(locale.toString(statistics.missedAbs), tr("N/A"));
+ } else {
+ onTimeBox->setValue(locale.toString(statistics.onTimeAbs), locale.toString(statistics.onTimeRel, 'g', 3) + "%");
+ shiftedBox->setValue(locale.toString(statistics.shiftedAbs), locale.toString(statistics.shiftedRel, 'g', 3) + "%");
+ missedBox->setValue(locale.toString(statistics.missedAbs), locale.toString(statistics.missedRel, 'g', 3) + "%");
+ }
+ avgShiftBox->setValue(locale.toString(statistics.avgShift, 'g', 3));
+ totalShiftDaysBox->setValue(locale.toString(statistics.totalShiftDaysAbs));
+ viewStack->setCurrentIndex(entries.count() > 0 ? 0 : 1);
+}
+
+
+void
+PlanAdherenceView::setDateRange
+(const DateRange &dr)
+{
+ dateRange = dr;
+ if (! dateInMonth.isValid()) {
+ dateInMonth = QDate::currentDate();
+ }
+ if (! dateRange.pass(dateInMonth)) {
+ QDate today = QDate::currentDate();
+ if (dateRange.pass(today)) {
+ dateInMonth = today;
+ } else if (dateRange.from.isValid() && dateInMonth < dateRange.from) {
+ dateInMonth = dateRange.from;
+ } else if (dateRange.to.isValid() && dateInMonth > dateRange.to) {
+ dateInMonth = dateRange.to;
+ }
+ }
+ emit monthChanged(dateInMonth);
+}
+
+
+void
+PlanAdherenceView::setMinAllowedOffset
+(int offset)
+{
+ timeline->setMinAllowedOffset(offset);
+}
+
+
+void
+PlanAdherenceView::setMaxAllowedOffset
+(int offset)
+{
+ timeline->setMaxAllowedOffset(offset);
+}
+
+
+void
+PlanAdherenceView::setPreferredStatisticsDisplay
+(int mode)
+{
+ StatisticBox::DisplayState state = (mode == 0) ? StatisticBox::DisplayState::Primary : StatisticBox::DisplayState::Secondary;
+ totalBox->setPreferredState(state);
+ plannedBox->setPreferredState(state);
+ unplannedBox->setPreferredState(state);
+ onTimeBox->setPreferredState(state);
+ shiftedBox->setPreferredState(state);
+ missedBox->setPreferredState(state);
+ avgShiftBox->setPreferredState(state);
+ totalShiftDaysBox->setPreferredState(state);
+}
+
+
+void
+PlanAdherenceView::populateDateMenu
+()
+{
+ dateMenu->clear();
+ dateMenu->addSection(tr("Season: %1").arg(dateRange.name));
+ dateMenu->setEnabled(true);
+
+ int yearFrom = dateRange.from.isValid() ? dateRange.from.year() : QDate::currentDate().addYears(-5).year();
+ int yearTo = dateRange.to.isValid() ? dateRange.to.year() : QDate::currentDate().addYears(5).year();
+ for (int year = yearFrom; year <= yearTo; ++year) {
+ QAction *action = dateMenu->addAction(QString::number(year));
+ if (year == dateInMonth.year()) {
+ action->setEnabled(false);
+ } else {
+ QDate date(year, 1, 1);
+ if (! dateRange.pass(date)) {
+ if (date.year() == dateRange.from.year()) {
+ date = dateRange.from;
+ } else {
+ date = dateRange.to;
+ }
+ }
+ connect(action, &QAction::triggered, this, [this, date]() {
+ dateInMonth = date;
+ emit monthChanged(dateInMonth);
+ });
+ }
+ }
+}
+
+
+void
+PlanAdherenceView::setNavButtonState
+()
+{
+ if (! dateInMonth.isValid()) {
+ return;
+ }
+ QDate lastOfPrevMonth(dateInMonth.addMonths(-1));
+ lastOfPrevMonth.setDate(lastOfPrevMonth.year(), lastOfPrevMonth.month(), lastOfPrevMonth.daysInMonth());
+ QDate firstOfNextMonth(dateInMonth.addMonths(1));
+ firstOfNextMonth.setDate(firstOfNextMonth.year(), firstOfNextMonth.month(), 1);
+ prevAction->setEnabled(dateRange.pass(lastOfPrevMonth));
+ nextAction->setEnabled(dateRange.pass(firstOfNextMonth));
+ todayAction->setEnabled(dateRange.pass(QDate::currentDate()));
+}
+
+
+void
+PlanAdherenceView::updateHeader
+()
+{
+ QLocale locale;
+ dateNavigator->setText(locale.toString(dateInMonth, "MMMM yyyy"));
+ prevAction->setVisible(true);
+ nextAction->setVisible(true);
+ todayAction->setVisible(true);
+ separator->setVisible(true);
+ dateNavigatorAction->setVisible(true);
+}
+
+
+void
+PlanAdherenceView::applyNavIcons
+()
+{
+ double scale = appsettings->value(this, GC_FONT_SCALE, 1.0).toDouble();
+ QFont font;
+ font.setPointSize(font.pointSizeF() * scale * 1.3);
+ font.setWeight(QFont::Bold);
+ dateNavigator->setFont(font);
+ QString mode = GCColor::isPaletteDark(palette()) ? "dark" : "light";
+ toolbar->setMinimumHeight(dateNavigator->sizeHint().height() + 12 * dpiYFactor);
+
+ prevAction->setIcon(QIcon(QString(":images/breeze/%1/go-previous.svg").arg(mode)));
+ nextAction->setIcon(QIcon(QString(":images/breeze/%1/go-next.svg").arg(mode)));
+}
+
+
+//////////////////////////////////////////////////////////////////////////////
+// static helpers
+
+static void
+sendFakeHoverMove
+(QWidget *viewport, const QPoint &pos)
+{
+ QPointF posF(pos);
+ QPointF globalF = viewport->mapToGlobal(posF);
+ QHoverEvent fakeHover(QEvent::HoverMove, posF, globalF, posF);
+ QCoreApplication::sendEvent(viewport, &fakeHover);
+}
+
+
+static void
+sendFakeHoverLeave
+(QWidget *viewport)
+{
+ QPointF outOfBounds(-1, -1);
+ QPointF globalOutOfBounds = viewport->mapToGlobal(outOfBounds);
+ QPointF oldPos = viewport->mapFromGlobal(QCursor::pos());
+ QHoverEvent fakeLeave(QEvent::HoverLeave, outOfBounds, globalOutOfBounds, oldPos);
+ QCoreApplication::sendEvent(viewport, &fakeLeave);
+}
diff --git a/src/Gui/PlanAdherence.h b/src/Gui/PlanAdherence.h
new file mode 100644
index 000000000..b508ea9b6
--- /dev/null
+++ b/src/Gui/PlanAdherence.h
@@ -0,0 +1,319 @@
+/*
+ * Copyright (c) 2026 Joachim Kohlhammer (joachim.kohlhammer@gmx.de)
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef PLANADHERENCE_H
+#define PLANADHERENCE_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "TimeUtils.h"
+#include "Colors.h"
+
+
+class StatisticBox : public QFrame {
+ Q_OBJECT
+
+public:
+ enum class DisplayState {
+ Primary,
+ Secondary
+ };
+
+ explicit StatisticBox(const QString &title, const QString &description, DisplayState startupPreferred = DisplayState::Primary, QWidget* parent = nullptr);
+
+ void setValue(const QString &primaryValue, const QString &secondaryValue = QString());
+ void setPreferredState(const DisplayState &state);
+
+protected:
+ void changeEvent(QEvent *event) override;
+ void mousePressEvent(QMouseEvent *event) override;
+
+private:
+ QLabel *valueLabel;
+ QLabel *titleLabel;
+ QString primaryValue;
+ QString secondaryValue;
+ QString description;
+ DisplayState startupPreferredState;
+ DisplayState currentPreferredState;
+ DisplayState currentState;
+};
+
+
+class JointLineTree : public QTreeWidget {
+ Q_OBJECT
+
+public:
+ explicit JointLineTree(QWidget *parent = nullptr);
+
+ bool isRowHovered(const QModelIndex &index) const;
+ virtual QColor getHoverBG() const;
+ virtual QColor getHoverHL() const;
+
+protected:
+ void mouseMoveEvent(QMouseEvent *event) override;
+ void wheelEvent(QWheelEvent *event) override;
+ void leaveEvent(QEvent *event) override;
+ void enterEvent(QEnterEvent *event) override;
+ void contextMenuEvent(QContextMenuEvent *event) override;
+ void changeEvent(QEvent *event) override;
+ bool viewportEvent(QEvent *event) override;
+ void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+
+ virtual QMenu *createContextMenu(const QModelIndex &index) = 0;
+ virtual QString createToolTipText(const QModelIndex &index) const = 0;
+
+ void clearHover();
+
+private:
+ QPersistentModelIndex hoveredIndex;
+ QPersistentModelIndex contextMenuIndex;
+
+ void updateHoveredIndex(const QPoint &pos);
+};
+
+
+struct PlanAdherenceStatistics {
+ int totalAbs = 0;
+ int plannedAbs = 0;
+ float plannedRel = 0;
+ int onTimeAbs = 0;
+ float onTimeRel = 0;
+ int shiftedAbs = 0;
+ float shiftedRel = 0;
+ int missedAbs = 0;
+ float missedRel = 0;
+ float avgShift = 0;
+ int unplannedAbs = 0;
+ float unplannedRel = 0;
+ int totalShiftDaysAbs = 0;
+};
+
+
+struct PlanAdherenceEntry {
+ QString titlePrimary;
+ QString titleSecondary;
+ QString iconFile;
+ QColor color;
+ QDate date;
+ bool isPlanned;
+ std::optional shiftOffset;
+ std::optional actualOffset;
+ QString plannedReference;
+ QString actualReference;
+};
+Q_DECLARE_METATYPE(PlanAdherenceEntry)
+
+
+struct PlanAdherenceOffsetRange {
+ qint64 min = -1;
+ qint64 max = 1;
+};
+
+
+class PlanAdherenceOffsetHandler {
+public:
+ void setMinAllowedOffset(int minOffset);
+ void setMaxAllowedOffset(int maxOffset);
+ void setMinEntryOffset(int minOffset);
+ void setMaxEntryOffset(int maxOffset);
+
+ bool hasMinOverflow() const;
+ bool hasMaxOverflow() const;
+
+ int minVisibleOffset() const;
+ int maxVisibleOffset() const;
+
+ bool isMinOverflow(int offset) const;
+ bool isMaxOverflow(int offset) const;
+ int indexFromOffset(int offset) const;
+ int numCells() const;
+
+protected:
+ int minAllowedOffset = -1;
+ int maxAllowedOffset = 1;
+ int minEntryOffset = -1;
+ int maxEntryOffset = 1;
+};
+
+
+namespace PlanAdherence {
+ enum Roles {
+ DataRole = Qt::UserRole + 1 // [PlanAdherenceEntry] Data to be visualized
+ };
+}
+
+
+class PlanAdherenceTitleDelegate : public QStyledItemDelegate {
+ Q_OBJECT
+
+public:
+ explicit PlanAdherenceTitleDelegate(QObject *parent = nullptr);
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+ QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+
+private:
+ const int leftPadding = 10 * dpiXFactor;
+ const int horMargin = 4 * dpiXFactor;
+ const int vertMargin = 6 * dpiYFactor;
+ const int lineSpacing = 2 * dpiYFactor;
+ const int iconInnerSpacing = 2 * dpiXFactor;
+ const int iconTextSpacing = 8 * dpiXFactor;
+ const int maxWidth = 300 * dpiXFactor;
+};
+
+
+class PlanAdherenceOffsetDelegate : public QStyledItemDelegate, public PlanAdherenceOffsetHandler {
+ Q_OBJECT
+
+public:
+ explicit PlanAdherenceOffsetDelegate(QObject *parent = nullptr);
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+ QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+
+private:
+ const int horMargin = 4 * dpiXFactor;
+ const int vertMargin = 6 * dpiYFactor;
+ const int lineSpacing = 2 * dpiYFactor;
+};
+
+
+class PlanAdherenceStatusDelegate : public QStyledItemDelegate {
+public:
+ enum Roles {
+ Line1Role = Qt::UserRole + 1, // [QString] First line to be shown
+ Line2Role, // [QString] Second line to be shown (optional, first line will be vertically centered if this is not available)
+ Line2WeightRole // [QFont::Weight] Font weight to be used for line 2
+ };
+
+ explicit PlanAdherenceStatusDelegate(QObject *parent = nullptr);
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+ QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+
+private:
+ const int rightPadding = 10 * dpiXFactor;
+ const int horMargin = 4 * dpiXFactor;
+ const int vertMargin = 6 * dpiYFactor;
+ const int lineSpacing = 2 * dpiYFactor;
+};
+
+
+class PlanAdherenceHeaderView : public QHeaderView, public PlanAdherenceOffsetHandler {
+ Q_OBJECT
+
+public:
+ explicit PlanAdherenceHeaderView(Qt::Orientation orientation, QWidget *parent = nullptr);
+
+protected:
+ void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const override;
+
+private:
+ const int horMargin = 4 * dpiXFactor;
+};
+
+
+class PlanAdherenceTree : public JointLineTree {
+ Q_OBJECT
+
+public:
+ explicit PlanAdherenceTree(QWidget *parent = nullptr);
+
+ void fillEntries(const QList &entries, const PlanAdherenceOffsetRange &offsets);
+
+ QColor getHoverBG() const;
+ QColor getHoverHL() const;
+
+ void setMinAllowedOffset(int offset);
+ void setMaxAllowedOffset(int offset);
+
+signals:
+ void viewActivity(QString reference, bool planned);
+
+protected:
+ QMenu *createContextMenu(const QModelIndex &index) override;
+ QString createToolTipText(const QModelIndex &index) const override;
+
+private:
+ PlanAdherenceOffsetDelegate *planAdherenceOffsetDelegate;
+ PlanAdherenceHeaderView *headerView;
+ int minAllowedOffset = -1;
+ int maxAllowedOffset = 5;
+};
+
+
+class PlanAdherenceView : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit PlanAdherenceView(QWidget *parent = nullptr);
+
+ QDate firstVisibleDay() const;
+ QDate lastVisibleDay() const;
+ void fillEntries(const QList &entries, const PlanAdherenceStatistics &statistics, const PlanAdherenceOffsetRange &offsets, bool isFiltered);
+
+public slots:
+ void setDateRange(const DateRange &dateRange);
+ void setMinAllowedOffset(int offset);
+ void setMaxAllowedOffset(int offset);
+ void setPreferredStatisticsDisplay(int mode);
+ void applyNavIcons();
+
+signals:
+ void monthChanged(QDate month);
+ void viewActivity(QString reference, bool planned);
+
+private:
+ QToolBar *toolbar;
+ QAction *prevAction;
+ QAction *nextAction;
+ QAction *separator;
+ QAction *todayAction;
+ QAction *dateNavigatorAction;
+ QAction *filterLabelAction;
+ QToolButton *dateNavigator;
+ QMenu *dateMenu;
+ QStackedWidget *viewStack;
+ PlanAdherenceTree *timeline;
+ StatisticBox *totalBox;
+ StatisticBox *plannedBox;
+ StatisticBox *unplannedBox;
+ StatisticBox *onTimeBox;
+ StatisticBox *shiftedBox;
+ StatisticBox *missedBox;
+ StatisticBox *avgShiftBox;
+ StatisticBox *totalShiftDaysBox;
+ DateRange dateRange;
+ QDate dateInMonth;
+
+ void setNavButtonState();
+ void updateHeader();
+
+private slots:
+ void populateDateMenu();
+};
+
+#endif
diff --git a/src/src.pro b/src/src.pro
index 35d4be8b6..833804ec7 100644
--- a/src/src.pro
+++ b/src/src.pro
@@ -575,7 +575,7 @@ HEADERS += Charts/Aerolab.h Charts/AerolabWindow.h Charts/AllPlot.h Charts/AllPl
Charts/MetadataWindow.h Charts/MUPlot.h Charts/MUPool.h Charts/MUWidget.h Charts/PfPvPlot.h Charts/PfPvWindow.h \
Charts/PowerHist.h Charts/ReferenceLineDialog.h Charts/RideEditor.h Charts/RideMapWindow.h \
Charts/ScatterPlot.h Charts/ScatterWindow.h Charts/SmallPlot.h Charts/TreeMapPlot.h \
- Charts/TreeMapWindow.h Charts/ZoneScaleDraw.h Charts/CalendarWindow.h Charts/AgendaWindow.h
+ Charts/TreeMapWindow.h Charts/ZoneScaleDraw.h Charts/CalendarWindow.h Charts/AgendaWindow.h Charts/PlanAdherenceWindow.h
# cloud services
HEADERS += Cloud/CalendarDownload.h Cloud/CloudService.h \
@@ -621,6 +621,7 @@ HEADERS += Gui/AboutDialog.h Gui/AddIntervalDialog.h Gui/AnalysisSidebar.h Gui/C
Gui/PerspectiveDialog.h Gui/SplashScreen.h Gui/StyledItemDelegates.h Gui/MetadataDialog.h Gui/ActionButtonBox.h \
Gui/MetricOverrideDialog.h Gui/RepeatScheduleWizard.h \
Gui/Calendar.h Gui/Agenda.h Gui/CalendarData.h Gui/CalendarItemDelegates.h \
+ Gui/PlanAdherence.h \
Gui/IconManager.h Gui/FilterSimilarDialog.h
# metrics and models
@@ -685,7 +686,7 @@ SOURCES += Charts/Aerolab.cpp Charts/AerolabWindow.cpp Charts/AllPlot.cpp Charts
Charts/MetadataWindow.cpp Charts/MUPlot.cpp Charts/MUWidget.cpp Charts/PfPvPlot.cpp Charts/PfPvWindow.cpp \
Charts/PowerHist.cpp Charts/ReferenceLineDialog.cpp Charts/RideEditor.cpp Charts/RideMapWindow.cpp \
Charts/ScatterPlot.cpp Charts/ScatterWindow.cpp Charts/SmallPlot.cpp Charts/TreeMapPlot.cpp \
- Charts/TreeMapWindow.cpp Charts/CalendarWindow.cpp Charts/AgendaWindow.cpp
+ Charts/TreeMapWindow.cpp Charts/CalendarWindow.cpp Charts/AgendaWindow.cpp Charts/PlanAdherenceWindow.cpp
## Cloud Services / Web resources
SOURCES += Cloud/CalendarDownload.cpp Cloud/CloudService.cpp \
@@ -734,6 +735,7 @@ SOURCES += Gui/AboutDialog.cpp Gui/AddIntervalDialog.cpp Gui/AnalysisSidebar.cpp
Gui/PerspectiveDialog.cpp Gui/SplashScreen.cpp Gui/StyledItemDelegates.cpp Gui/MetadataDialog.cpp Gui/ActionButtonBox.cpp \
Gui/MetricOverrideDialog.cpp Gui/RepeatScheduleWizard.cpp \
Gui/Calendar.cpp Gui/Agenda.cpp Gui/CalendarData.cpp Gui/CalendarItemDelegates.cpp \
+ Gui/PlanAdherence.cpp \
Gui/IconManager.cpp Gui/FilterSimilarDialog.cpp
## Models and Metrics