mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-04-13 12:42:20 +00:00
New chart: Plan Adherence (#4836)
* New chart to visualize the adherence to the current plan * Allowing retrospective analysis of behavior: Shifted, missed, completed, unplanned activities * Grouping is always on a monthly basis [publish binaries]
This commit is contained in:
committed by
GitHub
parent
d813585927
commit
bbf4722ef2
474
src/Charts/PlanAdherenceWindow.cpp
Normal file
474
src/Charts/PlanAdherenceWindow.cpp
Normal file
@@ -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 "<h4>"
|
||||
#define HLC "</h4>"
|
||||
|
||||
|
||||
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<RideItem*>::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<FieldDefinition> 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("<unknown>");
|
||||
}
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PlanAdherenceWindow::updateActivities
|
||||
()
|
||||
{
|
||||
QList<PlanAdherenceEntry> 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<float>(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();
|
||||
}
|
||||
}
|
||||
81
src/Charts/PlanAdherenceWindow.h
Normal file
81
src/Charts/PlanAdherenceWindow.h
Normal file
@@ -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 <QtGui>
|
||||
|
||||
#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
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QColor>
|
||||
|
||||
#include <utility>
|
||||
|
||||
#define ENTRY_TYPE_ACTUAL_ACTIVITY 0
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<GcWinID>(id));
|
||||
|
||||
@@ -82,7 +82,8 @@ enum gcwinid {
|
||||
Agenda=53,
|
||||
UserPlan=54,
|
||||
OverviewPlan=55,
|
||||
OverviewPlanBlank = 56
|
||||
OverviewPlanBlank = 56,
|
||||
PlanAdherence = 57
|
||||
};
|
||||
};
|
||||
typedef enum GcWindowTypes::gcwinid GcWinID;
|
||||
|
||||
1496
src/Gui/PlanAdherence.cpp
Normal file
1496
src/Gui/PlanAdherence.cpp
Normal file
File diff suppressed because it is too large
Load Diff
319
src/Gui/PlanAdherence.h
Normal file
319
src/Gui/PlanAdherence.h
Normal file
@@ -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 <QtGui>
|
||||
#include <QLabel>
|
||||
#include <QTreeWidget>
|
||||
#include <QHeaderView>
|
||||
#include <QStackedWidget>
|
||||
#include <QToolBar>
|
||||
#include <QStyledItemDelegate>
|
||||
|
||||
#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<qint64> shiftOffset;
|
||||
std::optional<qint64> 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<PlanAdherenceEntry> &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<PlanAdherenceEntry> &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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user