• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GothenburgBitFactory / taskwarrior / 29696588051

19 Jul 2026 05:19PM UTC coverage: 84.77% (-0.003%) from 84.773%
29696588051

push

github

web-flow
Fix scheduled date handling in recurring tasks (#4144)

* Scheduled attribute updated in handleRecurrence.

The "scheduled" attribute of tasks generated by recurring tasks is now
updated the same way the "wait" attribute is handled.

The difference between the original "scheduled" and "due" dates is
added to the "due" date of each generated task when a "scheduled" date is
present.

* Added overflow check in scheduled recurrence.

Signed-off-by: ashprice <gitcommit1@sl.ashprice.co.uk>

---------

Signed-off-by: ashprice <gitcommit1@sl.ashprice.co.uk>
Co-authored-by: Mauro Scomparin <scompo@gmail.com>

7 of 8 new or added lines in 1 file covered. (87.5%)

1 existing line in 1 file now uncovered.

19603 of 23125 relevant lines covered (84.77%)

24190.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.77
/src/recur.cpp
1
////////////////////////////////////////////////////////////////////////////////
2
//
3
// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez.
4
//
5
// Permission is hereby granted, free of charge, to any person obtaining a copy
6
// of this software and associated documentation files (the "Software"), to deal
7
// in the Software without restriction, including without limitation the rights
8
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
// copies of the Software, and to permit persons to whom the Software is
10
// furnished to do so, subject to the following conditions:
11
//
12
// The above copyright notice and this permission notice shall be included
13
// in all copies or substantial portions of the Software.
14
//
15
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
// SOFTWARE.
22
//
23
// https://www.opensource.org/licenses/mit-license.php
24
//
25
////////////////////////////////////////////////////////////////////////////////
26

27
#include <cmake.h>
28
// cmake.h include header must come first
29

30
#include <Context.h>
31
#include <Datetime.h>
32
#include <Duration.h>
33
#include <Lexer.h>
34
#include <feedback.h>
35
#include <format.h>
36
#include <pwd.h>
37
#include <recur.h>
38
#include <stdlib.h>
39
#include <sys/types.h>
40
#include <time.h>
41
#include <unicode.h>
42
#include <unistd.h>
43
#include <util.h>
44

45
#include <limits>
46
#include <optional>
47

48
// Add a `time_t` delta to a Datetime, checking for and returning nullopt on integer overflow.
49
std::optional<Datetime> checked_add_datetime(Datetime& base, time_t delta) {
53✔
50
  // Datetime::operator+ takes an integer delta, so check that range
51
  if (static_cast<time_t>(std::numeric_limits<int>::max()) < delta) {
53✔
52
    return std::nullopt;
1✔
53
  }
54

55
  // Check for time_t overflow in the Datetime.
56
  if (std::numeric_limits<time_t>::max() - base.toEpoch() < delta) {
52✔
57
    return std::nullopt;
1✔
58
  }
59
  return base + delta;
51✔
60
}
61

62
////////////////////////////////////////////////////////////////////////////////
63
// Scans all tasks, and for any recurring tasks, determines whether any new
64
// child tasks need to be generated to fill gaps.
65
void handleRecurrence() {
886✔
66
  // Recurrence can be disabled.
67
  // Note: This is currently a workaround for TD-44, TW-1520.
68
  if (!Context::getContext().config.getBoolean("recurrence")) return;
2,658✔
69

70
  auto tasks = Context::getContext().tdb2.pending_tasks();
884✔
71
  Datetime now;
884✔
72

73
  // Look at all tasks and find any recurring ones.
74
  for (auto& t : tasks) {
7,345✔
75
    if (t.getStatus() == Task::recurring) {
6,461✔
76
      // Generate a list of due dates for this recurring task, regardless of
77
      // the mask.
78
      std::vector<Datetime> due;
152✔
79
      if (!generateDueDates(t, due)) {
152✔
80
        // Determine the end date.
81
        t.setStatus(Task::deleted);
1✔
82
        Context::getContext().tdb2.modify(t);
1✔
83
        Context::getContext().footnote(onExpiration(t));
1✔
84
        continue;
1✔
85
      }
86

87
      // Get the mask from the parent task.
88
      auto mask = t.get("mask");
151✔
89

90
      // Iterate over the due dates, and check each against the mask.
91
      auto changed = false;
151✔
92
      unsigned int i = 0;
151✔
93
      for (auto& d : due) {
368✔
94
        if (mask.length() <= i) {
217✔
95
          changed = true;
121✔
96

97
          Task rec(t);                       // Clone the parent.
121✔
98
          rec.setStatus(Task::pending);      // Change the status.
121✔
99
          rec.set("uuid", uuid());           // New UUID.
363✔
100
          rec.set("parent", t.get("uuid"));  // Remember mom.
484✔
101
          rec.setAsNow("entry");             // New entry date.
121✔
102
          rec.set("due", format(d.toEpoch()));
363✔
103

104
          if (t.has("wait")) {
242✔
105
            Datetime old_wait(t.get_date("wait"));
2✔
106
            Datetime old_due(t.get_date("due"));
1✔
107
            Datetime due(d);
1✔
108
            auto wait = checked_add_datetime(due, old_wait - old_due);
1✔
109
            if (wait) {
1✔
110
              rec.set("wait", format(wait->toEpoch()));
3✔
111
            } else {
112
              rec.remove("wait");
×
113
            }
114
            rec.setStatus(Task::waiting);
1✔
115
            mask += 'W';
1✔
116
          } else {
117
            mask += '-';
120✔
118
            rec.setStatus(Task::pending);
120✔
119
          }
120

121
          if (t.has("scheduled")) {
242✔
122
            Datetime old_scheduled(t.get_date("scheduled"));
2✔
123
            Datetime old_due(t.get_date("due"));
1✔
124
            Datetime due(d);
1✔
125
            auto scheduled = checked_add_datetime(due, old_scheduled - old_due);
1✔
126
            if (scheduled)
1✔
127
              rec.set("scheduled", format(scheduled->toEpoch()));
3✔
128
            else
NEW
129
              rec.remove("scheduled");
×
130
          }
131

132
          rec.set("imask", i);
363✔
133
          rec.remove("mask");  // Remove the mask of the parent.
121✔
134

135
          // Add the new task to the DB.
136
          Context::getContext().tdb2.add(rec);
121✔
137
        }
121✔
138

139
        ++i;
217✔
140
      }
141

142
      // Only modify the parent if necessary.
143
      if (changed) {
151✔
144
        t.set("mask", mask);
76✔
145
        Context::getContext().tdb2.modify(t);
76✔
146

147
        if (Context::getContext().verbose("recur"))
228✔
148
          Context::getContext().footnote(
118✔
149
              format("Creating recurring task instance '{1}'", t.get("description")));
295✔
150
      }
151
    }
152✔
152
  }
153
}
884✔
154

155
////////////////////////////////////////////////////////////////////////////////
156
// Determine a start date (due), an optional end date (until), and an increment
157
// period (recur).  Then generate a set of corresponding dates.
158
//
159
// Returns false if the parent recurring task is depleted.
160
bool generateDueDates(Task& parent, std::vector<Datetime>& allDue) {
152✔
161
  // Determine due date, recur period and until date.
162
  Datetime due(parent.get_date("due"));
152✔
163
  if (due._date == 0) return false;
152✔
164

165
  std::string recur = parent.get("recur");
151✔
166

167
  bool specificEnd = false;
151✔
168
  Datetime until;
151✔
169
  if (parent.get("until") != "") {
302✔
170
    until = Datetime(parent.get("until"));
15✔
171
    specificEnd = true;
5✔
172
  }
173

174
  auto recurrence_limit = Context::getContext().config.getInteger("recurrence.limit");
302✔
175
  int recurrence_counter = 0;
151✔
176
  Datetime now;
151✔
177
  Datetime i = due;
151✔
178
  while (1) {
179
    allDue.push_back(i);
217✔
180

181
    if (specificEnd && i > until) {
217✔
182
      // If i > until, it means there are no more tasks to generate, and if the
183
      // parent mask contains all + or X, then there never will be another task
184
      // to generate, and this parent task may be safely reaped.
185
      auto mask = parent.get("mask");
3✔
186
      if (mask.length() == allDue.size() && mask.find('-') == std::string::npos) return false;
3✔
187

188
      return true;
3✔
189
    }
3✔
190

191
    if (i > now) ++recurrence_counter;
214✔
192

193
    if (recurrence_counter >= recurrence_limit) return true;
214✔
194
    auto next = getNextRecurrence(i, recur);
68✔
195
    if (next) {
68✔
196
      i = *next;
66✔
197
    } else {
198
      return true;
2✔
199
    }
200
  }
66✔
201

202
  return true;
203
}
151✔
204

205
////////////////////////////////////////////////////////////////////////////////
206
/// Determine the next recurrence of the given period.
207
///
208
/// If no such date can be calculated, such as with a very large period, returns
209
/// nullopt.
210
std::optional<Datetime> getNextRecurrence(Datetime& current, std::string& period) {
68✔
211
  auto m = current.month();
68✔
212
  auto d = current.day();
68✔
213
  auto y = current.year();
68✔
214
  auto ho = current.hour();
68✔
215
  auto mi = current.minute();
68✔
216
  auto se = current.second();
68✔
217

218
  // Some periods are difficult, because they can be vague.
219
  if (period == "monthly" || period == "P1M") {
68✔
220
    if (++m > 12) {
×
221
      m -= 12;
×
222
      ++y;
×
223
    }
224

225
    while (!Datetime::valid(y, m, d)) --d;
×
226

227
    return Datetime(y, m, d, ho, mi, se);
×
228
  }
229

230
  else if (period == "weekdays") {
68✔
231
    auto dow = current.dayOfWeek();
1✔
232
    int days;
233

234
    if (dow == 5)
1✔
235
      days = 3;
1✔
236
    else if (dow == 6)
×
237
      days = 2;
×
238
    else
239
      days = 1;
×
240

241
    return checked_add_datetime(current, days * 86400);
1✔
242
  }
243

244
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'm') {
67✔
245
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
246

247
    if (increment <= 0)
1✔
248
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
249
                   increment);
×
250

251
    m += increment;
1✔
252
    while (m > 12) {
1✔
253
      m -= 12;
×
254
      ++y;
×
255
    }
256

257
    while (!Datetime::valid(y, m, d)) --d;
1✔
258

259
    return Datetime(y, m, d, ho, mi, se);
1✔
260
  }
261

262
  else if (period[0] == 'P' && Lexer::isAllDigits(period.substr(1, period.length() - 2)) &&
66✔
263
           period[period.length() - 1] == 'M') {
×
264
    int increment = strtol(period.substr(1, period.length() - 2).c_str(), nullptr, 10);
×
265

266
    if (increment <= 0)
×
267
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
268
                   increment);
×
269

270
    m += increment;
×
271
    while (m > 12) {
×
272
      m -= 12;
×
273
      ++y;
×
274
    }
275

276
    while (!Datetime::valid(y, m, d)) --d;
×
277

278
    return Datetime(y, m, d);
×
279
  }
280

281
  else if (period == "quarterly" || period == "P3M") {
66✔
282
    m += 3;
×
283
    if (m > 12) {
×
284
      m -= 12;
×
285
      ++y;
×
286
    }
287

288
    while (!Datetime::valid(y, m, d)) --d;
×
289

290
    return Datetime(y, m, d, ho, mi, se);
×
291
  }
292

293
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'q') {
66✔
294
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
295

296
    if (increment <= 0) {
1✔
297
      Context::getContext().footnote(format(
×
298
          "Recurrence period '{1}' is equivalent to {2} and hence invalid.", period, increment));
299
      return std::nullopt;
×
300
    }
301

302
    m += 3 * increment;
1✔
303
    while (m > 12) {
3✔
304
      m -= 12;
2✔
305
      ++y;
2✔
306
    }
307

308
    while (!Datetime::valid(y, m, d)) --d;
1✔
309

310
    return Datetime(y, m, d, ho, mi, se);
1✔
311
  }
312

313
  else if (period == "semiannual" || period == "P6M") {
65✔
314
    m += 6;
×
315
    if (m > 12) {
×
316
      m -= 12;
×
317
      ++y;
×
318
    }
319

320
    while (!Datetime::valid(y, m, d)) --d;
×
321

322
    return Datetime(y, m, d, ho, mi, se);
×
323
  }
324

325
  else if (period == "bimonthly" || period == "P2M") {
65✔
326
    m += 2;
×
327
    if (m > 12) {
×
328
      m -= 12;
×
329
      ++y;
×
330
    }
331

332
    while (!Datetime::valid(y, m, d)) --d;
×
333

334
    return Datetime(y, m, d, ho, mi, se);
×
335
  }
336

337
  else if (period == "biannual" || period == "biyearly" || period == "P2Y") {
65✔
338
    y += 2;
×
339

340
    return Datetime(y, m, d, ho, mi, se);
×
341
  }
342

343
  else if (period == "annual" || period == "yearly" || period == "P1Y") {
65✔
344
    y += 1;
16✔
345

346
    // If the due data just happens to be 2/29 in a leap year, then simply
347
    // incrementing y is going to create an invalid date.
348
    if (m == 2 && d == 29) d = 28;
16✔
349

350
    return Datetime(y, m, d, ho, mi, se);
16✔
351
  }
352

353
  // Add the period to current, and we're done.
354
  std::string::size_type idx = 0;
49✔
355
  Duration p;
49✔
356
  if (!p.parse(period, idx)) {
49✔
357
    Context::getContext().footnote(
4✔
358
        format("Warning: The recurrence value '{1}' is not valid.", period));
8✔
359
    return std::nullopt;
2✔
360
  }
361

362
  return checked_add_datetime(current, p.toTime_t());
47✔
363
}
364

365
////////////////////////////////////////////////////////////////////////////////
366
// When the status of a recurring child task changes, the parent task must
367
// update it's mask.
368
void updateRecurrenceMask(Task& task) {
376✔
369
  auto uuid = task.get("parent");
376✔
370
  Task parent;
376✔
371

372
  if (uuid != "" && Context::getContext().tdb2.get(uuid, parent)) {
376✔
373
    unsigned int index = strtol(task.get("imask").c_str(), nullptr, 10);
64✔
374
    auto mask = parent.get("mask");
32✔
375
    if (mask.length() > index) {
32✔
376
      mask[index] = (task.getStatus() == Task::pending)     ? '-'
53✔
377
                    : (task.getStatus() == Task::completed) ? '+'
42✔
378
                    : (task.getStatus() == Task::deleted)   ? 'X'
21✔
379
                    : (task.getStatus() == Task::waiting)   ? 'W'
×
380
                                                            : '?';
381
    } else {
382
      std::string mask;
×
383
      for (unsigned int i = 0; i < index; ++i) mask += "?";
×
384

385
      mask += (task.getStatus() == Task::pending)     ? '-'
×
386
              : (task.getStatus() == Task::completed) ? '+'
×
387
              : (task.getStatus() == Task::deleted)   ? 'X'
×
388
              : (task.getStatus() == Task::waiting)   ? 'W'
×
389
                                                      : '?';
×
390
    }
×
391

392
    parent.set("mask", mask);
32✔
393
    Context::getContext().tdb2.modify(parent);
32✔
394
  }
32✔
395
}
376✔
396

397
////////////////////////////////////////////////////////////////////////////////
398
// Delete expired tasks.
399
void handleUntil() {
886✔
400
  Datetime now;
886✔
401
  auto tasks = Context::getContext().tdb2.pending_tasks();
886✔
402
  for (auto& t : tasks) {
7,349✔
403
    // TODO What about expiring template tasks?
404
    if (t.getStatus() == Task::pending && t.has("until")) {
19,039✔
405
      auto until = Datetime(t.get_date("until"));
14✔
406
      if (until < now) {
14✔
407
        Context::getContext().debug(format("handleUntil: recurrence expired until {1} < now {2}",
8✔
408
                                           until.toISOLocalExtended(), now.toISOLocalExtended()));
8✔
409
        t.setStatus(Task::deleted);
4✔
410
        Context::getContext().tdb2.modify(t);
4✔
411
        Context::getContext().footnote(onExpiration(t));
4✔
412
      }
413
    }
414
  }
415
}
886✔
416

417
////////////////////////////////////////////////////////////////////////////////
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc