diff --git a/src/Charts/PlanningCalendarWindow.cpp b/src/Charts/PlanningCalendarWindow.cpp
index 8aba624e5..89863592f 100644
--- a/src/Charts/PlanningCalendarWindow.cpp
+++ b/src/Charts/PlanningCalendarWindow.cpp
@@ -28,6 +28,7 @@
#include "RideMetadata.h"
#include "Colors.h"
#include "ManualActivityWizard.h"
+#include "RepeatScheduleWizard.h"
#include "WorkoutFilter.h"
#define HLO "
"
@@ -87,6 +88,15 @@ PlanningCalendarWindow::PlanningCalendarWindow(Context *context)
wizard.exec();
context->tab->setNoSwitch(false);
});
+ connect(calendar, &Calendar::repeatSchedule, [=](const QDate &day) {
+ context->tab->setNoSwitch(true);
+ RepeatScheduleWizard wizard(context, day);
+ if (wizard.exec() == QDialog::Accepted) {
+ // Context::rideDeleted is not always emitted, therefore forcing the update
+ updateActivities();
+ }
+ context->tab->setNoSwitch(false);
+ });
connect(calendar, &Calendar::delActivity, [=](CalendarEntry activity) {
QMessageBox::StandardButton res = QMessageBox::question(this, tr("Delete Activity"), tr("Are you sure you want to delete %1?").arg(activity.reference));
if (res == QMessageBox::Yes) {
diff --git a/src/Gui/Calendar.cpp b/src/Gui/Calendar.cpp
index 6db8e772e..14532368f 100644
--- a/src/Gui/Calendar.cpp
+++ b/src/Gui/Calendar.cpp
@@ -1,3 +1,21 @@
+/*
+ * Copyright (c) 2025 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 "Calendar.h"
#include
@@ -639,6 +657,9 @@ CalendarMonthTable::showContextMenu
}
emit addActivity(true, day.date, time);
});
+ contextMenu.addAction("Repeat schedule...", this, [=]() {
+ emit repeatSchedule(day.date);
+ });
bool hasPlannedActivity = false;
for (const CalendarEntry &calEntry : day.entries) {
if (calEntry.type == ENTRY_TYPE_PLANNED_ACTIVITY) {
@@ -719,6 +740,7 @@ Calendar::Calendar
connect(monthTable, &CalendarMonthTable::showInTrainMode, this, &Calendar::showInTrainMode);
connect(monthTable, &CalendarMonthTable::viewActivity, this, &Calendar::viewActivity);
connect(monthTable, &CalendarMonthTable::addActivity, this, &Calendar::addActivity);
+ connect(monthTable, &CalendarMonthTable::repeatSchedule, this, &Calendar::repeatSchedule);
connect(monthTable, &CalendarMonthTable::delActivity, this, &Calendar::delActivity);
connect(monthTable, &CalendarMonthTable::entryMoved, this, &Calendar::moveActivity);
connect(monthTable, &CalendarMonthTable::insertRestday, this, &Calendar::insertRestday);
diff --git a/src/Gui/Calendar.h b/src/Gui/Calendar.h
index 3f70cd454..abc0727d6 100644
--- a/src/Gui/Calendar.h
+++ b/src/Gui/Calendar.h
@@ -1,3 +1,21 @@
+/*
+ * Copyright (c) 2025 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 CALENDAR_H
#define CALENDAR_H
@@ -56,6 +74,7 @@ signals:
void viewActivity(const CalendarEntry &activity);
void addActivity(bool plan, const QDate &day, const QTime &time);
void delActivity(const CalendarEntry &activity);
+ void repeatSchedule(const QDate &day);
void insertRestday(const QDate &day);
void delRestday(const QDate &day);
@@ -121,6 +140,7 @@ signals:
void viewActivity(const CalendarEntry &activity);
void addActivity(bool plan, const QDate &day, const QTime &time);
void delActivity(const CalendarEntry &activity);
+ void repeatSchedule(const QDate &day);
void moveActivity(const CalendarEntry &activity, const QDate &srcDay, const QDate &destDay);
void insertRestday(const QDate &day);
void delRestday(const QDate &day);
diff --git a/src/Gui/RepeatScheduleWizard.cpp b/src/Gui/RepeatScheduleWizard.cpp
new file mode 100644
index 000000000..a0820030c
--- /dev/null
+++ b/src/Gui/RepeatScheduleWizard.cpp
@@ -0,0 +1,578 @@
+/*
+ * Copyright (c) 2025 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 "RepeatScheduleWizard.h"
+
+#include
+#include
+
+#include "Context.h"
+#include "Athlete.h"
+#include "AthleteTab.h"
+#include "Seasons.h"
+#include "Colors.h"
+
+#define ICON_COLOR QColor("#F79130")
+#ifdef Q_OS_MAC
+#define ICON_SIZE 250
+#define ICON_MARGIN 0
+#define ICON_TYPE QWizard::BackgroundPixmap
+#else
+#define ICON_SIZE 125
+#define ICON_MARGIN 5
+#define ICON_TYPE QWizard::LogoPixmap
+#endif
+
+static QString rideItemName(RideItem const * const rideItem);
+static QString rideItemSport(RideItem const * const rideItem);
+
+
+////////////////////////////////////////////////////////////////////////////////
+// RepeatScheduleWizard
+
+RepeatScheduleWizard::RepeatScheduleWizard
+(Context *context, const QDate &when, QWidget *parent)
+: QWizard(parent), context(context), when(when)
+{
+ setWindowTitle(tr("Repeat Schedule"));
+ setMinimumSize(800 * dpiXFactor, 650 * dpiYFactor);
+ setModal(true);
+
+#ifdef Q_OS_MAC
+ setWizardStyle(QWizard::MacStyle);
+#else
+ setWizardStyle(QWizard::ModernStyle);
+#endif
+ setPixmap(ICON_TYPE, svgAsColoredPixmap(":images/breeze/media-playlist-repeat.svg", QSize(ICON_SIZE * dpiXFactor, ICON_SIZE * dpiYFactor), ICON_MARGIN * dpiXFactor, ICON_COLOR));
+
+ setPage(PageSetup, new RepeatSchedulePageSetup(context, when));
+ setPage(PageActivities, new RepeatSchedulePageActivities(context));
+ setPage(PageSummary, new RepeatSchedulePageSummary(context, when));
+ setStartId(PageSetup);
+}
+
+
+void
+RepeatScheduleWizard::done
+(int result)
+{
+ int finalResult = result;
+ if (result == QDialog::Accepted) {
+ QApplication::setOverrideCursor(QCursor(Qt::WaitCursor));
+ RepeatSchedulePageSummary *summaryPage = qobject_cast(page(PageSummary));
+ QList deletionList = summaryPage->getDeletionList();
+ QList> scheduleList = summaryPage->getScheduleList();
+ context->tab->setNoSwitch(true);
+ for (RideItem *rideItem : deletionList) {
+ context->athlete->rideCache->removeRide(rideItem->fileName);
+ }
+ for (std::pair entry : scheduleList) {
+ RideItem *rideItem = entry.first;
+ QDate targetDate = entry.second;
+ RideFile *rideFile = rideItem->ride();
+ QDateTime rideDateTime(targetDate, rideFile->startTime().time());
+ rideFile->setStartTime(rideDateTime);
+ QString basename = rideDateTime.toString("yyyy_MM_dd_HH_mm_ss");
+ QString filename = context->athlete->home->planned().canonicalPath() + "/" + basename + ".json";
+ QFile out(filename);
+ if (RideFileFactory::instance().writeRideFile(context, rideFile, out, "json")) {
+ context->athlete->addRide(basename + ".json", true, true, false, rideItem->planned);
+ }
+ }
+ context->tab->setNoSwitch(false);
+ QApplication::restoreOverrideCursor();
+ }
+
+ QWizard::done(finalResult);
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+// RepeatSchedulePageSetup
+
+RepeatSchedulePageSetup::RepeatSchedulePageSetup
+(Context *context, const QDate &when, QWidget *parent)
+: QWizardPage(parent), context(context)
+{
+ setTitle(tr("Repeat Schedule Setup"));
+ setSubTitle(tr("Specify the time range and strategy for repeating the schedule. All planned activities within this range will be copied. You can optionally select a season or phase to prefill the start and end dates."));
+
+ QTreeWidget *seasonTree = new QTreeWidget();
+ seasonTree->setColumnCount(1);
+ basicTreeWidgetStyle(seasonTree, false);
+ seasonTree->setHeaderHidden(true);
+ seasonTree->resetIndentation();
+ QTreeWidgetItem *currentSeason = nullptr;
+ for (const Season &season : context->athlete->seasons->seasons) {
+ QTreeWidgetItem *seasonItem = new QTreeWidgetItem();
+ seasonItem->setData(0, Qt::DisplayRole, season.getName());
+ seasonItem->setData(0, Qt::UserRole, season.getStart());
+ seasonItem->setData(0, Qt::UserRole + 1, season.getEnd());
+ if (context->currentSeason() != nullptr && context->currentSeason()->id() == season.id()) {
+ currentSeason = seasonItem;
+ }
+ for (const Phase &phase : season.phases) {
+ QTreeWidgetItem *phaseItem = new QTreeWidgetItem();
+ phaseItem->setData(0, Qt::DisplayRole, phase.getName());
+ phaseItem->setData(0, Qt::UserRole, phase.getStart());
+ phaseItem->setData(0, Qt::UserRole + 1, phase.getEnd());
+ seasonItem->addChild(phaseItem);
+ if (context->currentSeason() != nullptr && context->currentSeason()->id() == phase.id()) {
+ currentSeason = phaseItem;
+ }
+ }
+ seasonTree->addTopLevelItem(seasonItem);
+ }
+
+ QSpinBox *restDayBox = new QSpinBox();
+ restDayBox->setSuffix(" " + tr("active days"));
+ restDayBox->setValue(3);
+ restDayBox->setRange(1, 99);
+
+ QComboBox *conflictBox = new QComboBox();
+ conflictBox->addItem("Delete all pre-existing activities");
+ conflictBox->addItem("Skip days with pre-existing activities");
+ conflictBox->addItem("Fail for all");
+
+ QDateEdit *startDate = new QDateEdit();
+ startDate->setMaximumDate(when.addDays(-1));
+
+ QDateEdit *endDate = new QDateEdit();
+ endDate->setMaximumDate(when.addDays(-1));
+
+ QCheckBox *sameDayCheck = new QCheckBox(tr("Copy same-day activities to consecutive days"));
+
+ registerField("startDate", startDate);
+ registerField("endDate", endDate);
+ registerField("restDayHandling", restDayBox);
+ registerField("sameDay", sameDayCheck);
+ registerField("conflictHandling", conflictBox);
+
+ QFormLayout *form = newQFormLayout();
+ form->addRow(seasonTree);
+ form->addRow(tr("Start Date"), startDate);
+ form->addRow(tr("End Date"), endDate);
+ form->addRow(tr("Insert rest day after"), restDayBox);
+ form->addRow("", sameDayCheck);
+ form->addRow(tr("Conflict Handling"), conflictBox);
+
+ QWidget *scrollWidget = new QWidget();
+ QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget);
+ scrollLayout->addWidget(centerLayoutInWidget(form, false));
+ QScrollArea *scrollArea = new QScrollArea();
+ scrollArea->setFrameShape(QFrame::NoFrame);
+ scrollArea->setWidget(scrollWidget);
+ scrollArea->setWidgetResizable(true);
+
+ QVBoxLayout *all = new QVBoxLayout();
+ all->addWidget(scrollArea);
+ setLayout(all);
+
+ connect(seasonTree, &QTreeWidget::currentItemChanged, [=](QTreeWidgetItem *current) {
+ if (current != nullptr) {
+ QDate seasonStart(current->data(0, Qt::UserRole).toDate());
+ QDate seasonEnd(current->data(0, Qt::UserRole + 1).toDate());
+ if (seasonEnd > when) {
+ seasonEnd = when;
+ }
+ if (seasonStart > when) {
+ seasonEnd = when;
+ }
+ startDate->setDate(seasonStart);
+ endDate->setDate(seasonEnd);
+ }
+ });
+ connect(startDate, &QDateEdit::dateChanged, [=](QDate date) {
+ endDate->setMinimumDate(date);
+ });
+ connect(endDate, &QDateEdit::dateChanged, [=](QDate date) {
+ startDate->setMaximumDate(date);
+ });
+
+ if (currentSeason != nullptr) {
+ seasonTree->setCurrentItem(currentSeason);
+ } else {
+ startDate->setDate(QDate::currentDate().addDays(-7));
+ endDate->setDate(QDate::currentDate().addDays(-1));
+ }
+}
+
+
+int
+RepeatSchedulePageSetup::nextId
+() const
+{
+ return RepeatScheduleWizard::PageActivities;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+// RepeatSchedulePageActivities
+
+RepeatSchedulePageActivities::RepeatSchedulePageActivities
+(Context *context, QWidget *parent)
+: QWizardPage(parent), context(context)
+{
+ setTitle(tr("Repeat Schedule Activities"));
+ setSubTitle(tr("Review and choose the activities you wish to add to your schedule."));
+
+ setFinalPage(false);
+
+ activityTree = new QTreeWidget();
+ activityTree->setColumnCount(4);
+ basicTreeWidgetStyle(activityTree, false);
+ activityTree->setHeaderHidden(true);
+
+ QWidget *formWidget = new QWidget();
+ QVBoxLayout *form = new QVBoxLayout(formWidget);
+ form->addWidget(new QLabel("" + tr("Activities for your new schedule") + "
addWidget(activityTree);
+
+ QWidget *scrollWidget = new QWidget();
+ QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget);
+ scrollLayout->addWidget(formWidget);
+ QScrollArea *scrollArea = new QScrollArea();
+ scrollArea->setFrameShape(QFrame::NoFrame);
+ scrollArea->setWidget(scrollWidget);
+ scrollArea->setWidgetResizable(true);
+
+ QVBoxLayout *all = new QVBoxLayout();
+ all->addWidget(scrollArea);
+ setLayout(all);
+}
+
+
+int
+RepeatSchedulePageActivities::nextId
+() const
+{
+ return RepeatScheduleWizard::PageSummary;
+}
+
+
+void
+RepeatSchedulePageActivities::initializePage
+()
+{
+ activityTree->clear();
+ QDate startDate = field("startDate").toDate();
+ QDate endDate = field("endDate").toDate();
+ QLocale locale;
+ numSelected = 0;
+ for (RideItem *rideItem : context->athlete->rideCache->rides()) {
+ if ( rideItem == nullptr
+ || ! rideItem->planned
+ || rideItem->dateTime.date() < startDate
+ || rideItem->dateTime.date() > endDate) {
+ continue;
+ }
+ if (context->isfiltered && ! context->filters.contains(rideItem->fileName)) {
+ continue;
+ }
+ ++numSelected;
+ QTreeWidgetItem *item = new QTreeWidgetItem();
+ item->setData(1, Qt::DisplayRole, locale.toString(rideItem->dateTime.date(), QLocale::ShortFormat));
+ item->setData(1, Qt::UserRole, QVariant::fromValue(rideItem));
+ item->setData(1, Qt::UserRole + 1, true);
+ item->setData(2, Qt::DisplayRole, rideItemSport(rideItem));
+ item->setData(3, Qt::DisplayRole, rideItemName(rideItem));
+ activityTree->addTopLevelItem(item);
+
+ QCheckBox *selectionBox = new QCheckBox();
+ selectionBox->setChecked(true);
+ QWidget *selectionWidget = new QWidget(activityTree);
+ QVBoxLayout *layout = new QVBoxLayout(selectionWidget);
+ layout->addWidget(selectionBox, 0, Qt::AlignCenter);
+ activityTree->setItemWidget(item, 0, selectionWidget);
+ connect(selectionBox, &QCheckBox::toggled, [=](bool checked) {
+ item->setData(1, Qt::UserRole + 1, checked);
+ numSelected += checked ? 1 : -1;
+ emit completeChanged();
+ });
+ }
+}
+
+
+bool
+RepeatSchedulePageActivities::isComplete
+() const
+{
+ return numSelected > 0;
+}
+
+
+QList
+RepeatSchedulePageActivities::getSelectedRideItems
+() const
+{
+ QList ret;
+ for (int i = 0; i < activityTree->topLevelItemCount(); ++i) {
+ QTreeWidgetItem *item = activityTree->topLevelItem(i);
+ if (item->data(1, Qt::UserRole + 1).toBool()) {
+ ret << item->data(1, Qt::UserRole).value();
+ }
+ }
+ return ret;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+// RepeatSchedulePageSummary
+
+RepeatSchedulePageSummary::RepeatSchedulePageSummary
+(Context *context, const QDate &when, QWidget *parent)
+: QWizardPage(parent), context(context), when(when)
+{
+ setTitle(tr("Repeat Schedule Summary"));
+ setSubTitle(tr("Preview the schedule updates, including planned additions and deletions. No changes will be made until you continue."));
+
+ setFinalPage(true);
+
+ failedLabel = new QLabel(""
+ + tr("Unable to create a new schedule due to conflicts")
+ + "
"
+ + tr("Adjust conflict handling on the first page to proceed.")
+ + "");
+ failedLabel->setWordWrap(true);
+
+ scheduleLabel = new QLabel("" + tr("New Schedule Overview") + "
setColumnCount(5);
+ basicTreeWidgetStyle(scheduleTree, false);
+ scheduleTree->setHeaderHidden(true);
+
+ deletionLabel = new QLabel("" + tr("Planned Activities Marked for Deletion") + "
");
+ deletionTree = new QTreeWidget();
+ deletionTree->setColumnCount(3);
+ basicTreeWidgetStyle(deletionTree, false);
+ deletionTree->setHeaderHidden(true);
+
+ QWidget *formWidget = new QWidget();
+ QVBoxLayout *form = new QVBoxLayout(formWidget);
+ form->addWidget(failedLabel);
+ form->addWidget(scheduleLabel);
+ form->addWidget(scheduleTree);
+ form->addWidget(deletionLabel);
+ form->addWidget(deletionTree);
+
+ QWidget *scrollWidget = new QWidget();
+ QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget);
+ scrollLayout->addWidget(formWidget);
+ QScrollArea *scrollArea = new QScrollArea();
+ scrollArea->setFrameShape(QFrame::NoFrame);
+ scrollArea->setWidget(scrollWidget);
+ scrollArea->setWidgetResizable(true);
+
+ QVBoxLayout *all = new QVBoxLayout();
+ all->addWidget(scrollArea);
+ setLayout(all);
+}
+
+
+int
+RepeatSchedulePageSummary::nextId
+() const
+{
+ return RepeatScheduleWizard::Finalize;
+}
+
+
+void
+RepeatSchedulePageSummary::initializePage
+()
+{
+ failed = false;
+ scheduleList.clear();
+ deletionList.clear();
+
+ scheduleTree->clear();
+ deletionTree->clear();
+
+ failedLabel->setVisible(false);
+ scheduleLabel->setVisible(false);
+ scheduleTree->setVisible(false);
+ deletionLabel->setVisible(false);
+ deletionTree->setVisible(false);
+
+ int restDayAfter = field("restDayHandling").toInt();
+ bool sameDay = field("sameDay").toBool();
+ int conflictHandling = field("conflictHandling").toInt();
+
+ QList preexistingPlanned; // Currently planned activities with date > when
+ QHash preexistingCount; // Number of preexisting activities with date > when per date
+ for (RideItem *rideItem : context->athlete->rideCache->rides()) {
+ if ( rideItem == nullptr
+ || ! rideItem->planned
+ || rideItem->dateTime.date() < when) {
+ continue;
+ }
+ if (context->isfiltered && ! context->filters.contains(rideItem->fileName)) {
+ continue;
+ }
+ preexistingPlanned << rideItem;
+ preexistingCount.insert(rideItem->dateTime.date(), preexistingCount.value(rideItem->dateTime.date(), 0) + 1);
+ }
+
+ RepeatSchedulePageActivities *activitiesPage = qobject_cast(wizard()->page(RepeatScheduleWizard::PageActivities));
+ QList selectedItems = activitiesPage->getSelectedRideItems(); // list of all selected activities that are to be copied to after when
+ QHash selectedCount; // Number of selected activities per date
+ for (RideItem *rideItem : selectedItems) {
+ selectedCount.insert(rideItem->dateTime.date(), selectedCount.value(rideItem->dateTime.date(), 0) + 1);
+ }
+
+ QDate nextAddDate(when);
+ QDate lastSourceDate;
+ QDate minDate;
+ QDate maxDate;
+ int activeDays = 0;
+ for (RideItem *rideItem : selectedItems) {
+ bool found = sameDay && lastSourceDate.isValid() && lastSourceDate == rideItem->dateTime.date();
+ while (! found) {
+ bool hasPreexisting = preexistingCount.value(nextAddDate, 0) > 0;
+ if (hasPreexisting) {
+ if (conflictHandling == 1) { // Skip days with preexisting
+ nextAddDate = nextAddDate.addDays(1);
+ ++activeDays;
+ continue;
+ } else if (conflictHandling == 2) { // Fail
+ failed = true;
+ break;
+ }
+ }
+ if (activeDays >= restDayAfter) {
+ activeDays = 0;
+ found = false;
+ nextAddDate = nextAddDate.addDays(1);
+ continue;
+ }
+ found = true;
+ }
+ if (failed) {
+ deletionList.clear();
+ scheduleList.clear();
+ break;
+ }
+ scheduleList << std::make_pair(rideItem, nextAddDate);
+ if (conflictHandling == 0) {
+ if (! minDate.isValid() || minDate > nextAddDate) {
+ minDate = nextAddDate;
+ }
+ if (! maxDate.isValid() || maxDate < nextAddDate) {
+ maxDate = nextAddDate;
+ }
+ }
+
+ int remaining = selectedCount.value(rideItem->dateTime.date(), 1) - 1;
+ selectedCount.insert(rideItem->dateTime.date(), remaining);
+ if (! (sameDay && remaining > 0)) {
+ nextAddDate = nextAddDate.addDays(1);
+ ++activeDays;
+ }
+ lastSourceDate = rideItem->dateTime.date();
+ }
+ if (! failed) {
+ QLocale locale;
+ if (minDate.isValid() && maxDate.isValid()) {
+ for (RideItem *rideItem : preexistingPlanned) {
+ if (rideItem->dateTime.date() >= minDate && rideItem->dateTime.date() <= maxDate) {
+ deletionList << rideItem;
+ }
+ }
+ }
+ if (! scheduleList.isEmpty()) {
+ scheduleLabel->setVisible(true);
+ scheduleTree->setVisible(true);
+ for (std::pair entry : scheduleList) {
+ QTreeWidgetItem *scheduleItem = new QTreeWidgetItem();
+ scheduleItem->setData(0, Qt::DisplayRole, locale.toString(entry.first->dateTime.date(), QLocale::ShortFormat));
+ scheduleItem->setData(1, Qt::DisplayRole, "→");
+ scheduleItem->setData(2, Qt::DisplayRole, locale.toString(entry.second, QLocale::ShortFormat));
+ scheduleItem->setData(3, Qt::DisplayRole, rideItemSport(entry.first));
+ scheduleItem->setData(4, Qt::DisplayRole, rideItemName(entry.first));
+ scheduleTree->addTopLevelItem(scheduleItem);
+ }
+ }
+ if (! deletionList.isEmpty()) {
+ deletionLabel->setVisible(true);
+ deletionTree->setVisible(true);
+ for (RideItem *rideItem : deletionList) {
+ QTreeWidgetItem *deletionItem = new QTreeWidgetItem();
+ deletionItem->setData(0, Qt::DisplayRole, locale.toString(rideItem->dateTime.date(), QLocale::ShortFormat));
+ deletionItem->setData(1, Qt::DisplayRole, rideItemSport(rideItem));
+ deletionItem->setData(2, Qt::DisplayRole, rideItemName(rideItem));
+ deletionTree->addTopLevelItem(deletionItem);
+ }
+ }
+ } else {
+ failedLabel->setVisible(true);
+ }
+}
+
+
+bool
+RepeatSchedulePageSummary::isComplete
+() const
+{
+ return ! failed;
+}
+
+
+QList
+RepeatSchedulePageSummary::getDeletionList
+() const
+{
+ return deletionList;
+}
+
+
+QList>
+RepeatSchedulePageSummary::getScheduleList
+() const
+{
+ return scheduleList;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+// Helpers
+
+QString
+rideItemName
+(RideItem const * const rideItem)
+{
+ QString rideName = rideItem->getText("Route", "").trimmed();
+ if (rideName.isEmpty()) {
+ rideName = rideItem->getText("Workout Code", "").trimmed();
+ if (rideName.isEmpty()) {
+ rideName = QObject::tr("");
+ }
+ }
+ return rideName;
+}
+
+
+QString
+rideItemSport
+(RideItem const * const rideItem)
+{
+ QString sport = rideItem->sport;
+ if (! rideItem->getText("SubSport", "").isEmpty()) {
+ sport += " / " + rideItem->getText("SubSport", "");
+ }
+ return sport;
+}
diff --git a/src/Gui/RepeatScheduleWizard.h b/src/Gui/RepeatScheduleWizard.h
new file mode 100644
index 000000000..02c2d8a8c
--- /dev/null
+++ b/src/Gui/RepeatScheduleWizard.h
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2025 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_RepeatScheduleWizard_h
+#define _GC_RepeatScheduleWizard_h 1
+
+#include "GoldenCheetah.h"
+
+#include
+#include
+#include
+#include
+#include
+
+
+class RepeatScheduleWizard : public QWizard
+{
+ Q_OBJECT
+
+ public:
+ enum {
+ Finalize = -1,
+ PageSetup,
+ PageActivities,
+ PageSummary
+ };
+
+ RepeatScheduleWizard(Context *context, const QDate &when, QWidget *parent = nullptr);
+
+ protected:
+ virtual void done(int result) override;
+
+ private:
+ Context *context;
+ QDate when;
+};
+
+
+class RepeatSchedulePageSetup : public QWizardPage
+{
+ Q_OBJECT
+
+ public:
+ RepeatSchedulePageSetup(Context *context, const QDate &when, QWidget *parent = nullptr);
+
+ int nextId() const override;
+
+ private:
+ Context *context;
+};
+
+
+class RepeatSchedulePageActivities : public QWizardPage
+{
+ Q_OBJECT
+
+ public:
+ RepeatSchedulePageActivities(Context *context, QWidget *parent = nullptr);
+
+ int nextId() const override;
+ void initializePage() override;
+ bool isComplete() const override;
+
+ QList getSelectedRideItems() const;
+
+ private:
+ Context *context;
+ QTreeWidget *activityTree;
+ int numSelected = 0;
+};
+
+
+class RepeatSchedulePageSummary : public QWizardPage
+{
+ Q_OBJECT
+
+ public:
+ RepeatSchedulePageSummary(Context *context, const QDate &when, QWidget *parent = nullptr);
+
+ int nextId() const override;
+ void initializePage() override;
+ bool isComplete() const override;
+
+ QList getDeletionList() const;
+ QList> getScheduleList() const;
+
+ private:
+ Context *context;
+ QDate when;
+
+ bool failed = false;
+ QList deletionList; // was: preexistingPlanned
+ QList> scheduleList; // was: targetMap
+
+ QLabel *failedLabel;
+ QLabel *scheduleLabel;
+ QTreeWidget *scheduleTree;
+ QLabel *deletionLabel;
+ QTreeWidget *deletionTree;
+};
+
+#endif // _GC_ManualActivityWizard_h
diff --git a/src/Resources/application.qrc b/src/Resources/application.qrc
index 81d2686d5..58127defd 100644
--- a/src/Resources/application.qrc
+++ b/src/Resources/application.qrc
@@ -124,6 +124,7 @@
xml/home-perspectives.xml
ini/measures.ini
html/ltm-summary.html
+ images/breeze/media-playlist-repeat.svg
images/breeze/games-highscores.svg
images/breeze/network-mobile-0.svg
images/breeze/network-mobile-20.svg
diff --git a/src/Resources/images/breeze/media-playlist-repeat.svg b/src/Resources/images/breeze/media-playlist-repeat.svg
new file mode 100644
index 000000000..5af564203
--- /dev/null
+++ b/src/Resources/images/breeze/media-playlist-repeat.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/src.pro b/src/src.pro
index 90cca5f16..6127e87ae 100644
--- a/src/src.pro
+++ b/src/src.pro
@@ -650,7 +650,7 @@ HEADERS += Gui/AboutDialog.h Gui/AddIntervalDialog.h Gui/AnalysisSidebar.h Gui/C
Gui/MergeActivityWizard.h Gui/RideImportWizard.h Gui/SplitActivityWizard.h Gui/SolverDisplay.h Gui/MetricSelect.h \
Gui/AddTileWizard.h Gui/NavigationModel.h Gui/AthleteView.h Gui/AthleteConfigDialog.h Gui/AthletePages.h Gui/Perspective.h \
Gui/PerspectiveDialog.h Gui/SplashScreen.h Gui/StyledItemDelegates.h Gui/MetadataDialog.h Gui/ActionButtonBox.h \
- Gui/MetricOverrideDialog.h \
+ Gui/MetricOverrideDialog.h Gui/RepeatScheduleWizard.h \
Gui/Calendar.h Gui/CalendarData.h Gui/CalendarItemDelegates.h
# metrics and models
@@ -762,7 +762,7 @@ SOURCES += Gui/AboutDialog.cpp Gui/AddIntervalDialog.cpp Gui/AnalysisSidebar.cpp
Gui/MergeActivityWizard.cpp Gui/RideImportWizard.cpp Gui/SplitActivityWizard.cpp Gui/SolverDisplay.cpp Gui/MetricSelect.cpp \
Gui/AddTileWizard.cpp Gui/NavigationModel.cpp Gui/AthleteView.cpp Gui/AthleteConfigDialog.cpp Gui/AthletePages.cpp Gui/Perspective.cpp \
Gui/PerspectiveDialog.cpp Gui/SplashScreen.cpp Gui/StyledItemDelegates.cpp Gui/MetadataDialog.cpp Gui/ActionButtonBox.cpp \
- Gui/MetricOverrideDialog.cpp \
+ Gui/MetricOverrideDialog.cpp Gui/RepeatScheduleWizard.cpp \
Gui/Calendar.cpp Gui/CalendarItemDelegates.cpp
## Models and Metrics