Recurring Tasks Developer Guide - edmozley/freeitsm GitHub Wiki
Repeating tasks โ Developer Guide
How recurrence is put together, and the traps that were hit building it.
The user page is Repeating tasks. Related: Tasks ยท Scheduled tasks (cron jobs)
Built for discussion #94 (dschipfel).
1. ๐ The files involved
| File | What it does |
|---|---|
includes/services/task_recurrence.php |
The whole engine. Date maths, sanitising, spawning, catch-up, projection, preview |
includes/services/tasks.php |
Calls TaskRecurrence::onTaskClosed() from both completion paths |
cron/task_recurrence.php |
The daily worker for fixed schedules |
api/tasks/save_recurrence.php |
Create, change or stop the rule on a task |
api/tasks/recurrence_preview.php |
Dates a rule would produce; writes nothing |
api/tasks/projected.php |
Future occurrences for the calendar; writes nothing |
assets/js/tasks.js |
The editor, the preview modal, the series links |
assets/js/tasks-calendar.js |
Draws projections alongside real tasks |
assets/js/tasks-ctx-menu.js |
"Set up a repeat" on the right-click menu |
tests/task-recurrence-dates.php |
60 assertions, no database |
tests/task-recurrence-spawn.php |
21 assertions, touches the database |
2. Schema
One new table and two new columns. Registered in includes/db_verify_schema.php, includes/db_verify_indexes.php and database/freeitsm.sql.
task_recurrences:
| Column | Notes |
|---|---|
mode |
completion | schedule |
freq |
daily | weekly | monthly | yearly |
interval_n |
1โ365 |
weekdays |
CSV of ISO 1โ7, weekly only |
month_mode |
dom | nth |
day_of_month |
-1 means the last day, so the floor is -1, not 1 |
nth, nth_weekday |
nth also takes -1 for "last" |
month_of_year |
yearly only |
ends_mode |
never | on_date | after_count |
ends_on, max_occurrences |
one or the other, per ends_mode |
occurrences_created |
seeded to 1 โ the task the rule was created on counts |
next_due_date |
fixed schedules only; NULL in completion mode |
is_active |
stopping a series clears this and deletes nothing |
On tasks:
recurrence_idโ the rule that made this taskrecurrence_master_idโ the first task of the series, so any occurrence can link back
Both foreign keys are ON DELETE SET NULL. Deleting a rule must not delete the work it produced.
Index: ix_task_recurrences_due (is_active, next_due_date).
3. Two spawning routes, and why the hook is where it is
Completion mode spawns inside the web request, from TasksService. There are two ways a task gets completed and both must fire:
saveTask()โ ticking the statusmoveTask()โ dragging a card into a closed column
moveTask is documented "No workflow event" and dispatches nothing, so a hook placed only on saveTask would give a task that repeats when you tick it and silently does not when you drag it โ and dragging is the commoner action. Both call one private wrapper:
private static function recurrenceOnClosed(PDO $conn, int $taskId): void
It is wrapped rather than called directly for two reasons: one place to change, and a try/catch so a missing recurrence service โ an install part-way through an upgrade โ cannot break completing a task. It runs outside the transaction: the completion is committed and must stand whatever the recurrence does.
Fixed schedules spawn from cron/task_recurrence.php โ runDue().
4. ๐ด An occurrence is created when work can START
The single most important rule, and it was wrong in the first release.
$begins = self::startForDue($sourceTask, $due) ?: $due;
if ($begins > $today) break; // not yet โ a later run will take it
A repeat keeps the gap between the start date and the due date, so a fortnight of work due on the 31st starts on the 17th. The first implementation created the occurrence when the due date arrived, which meant it came into existence already a fortnight past the day it should have been started โ on the Gantt, a two-week bar lying entirely in the past that nobody ever had a chance to do.
Consequences worth knowing:
runDue()'s SQL is deliberately not filtered onnext_due_date <= today. A series due in a fortnight may be ready to start today, and SQL cannot see the gap โ it lives on the source task. The date is decided per series in PHP, with the task in hand.- A task with no start date is a point-in-time job and still appears on its due date.
- There is no "days of notice" setting on purpose. The lead time is the task's own duration, which the user has already supplied.
5. ๐ด Date arithmetic is UTC DateTimeImmutable, never timestamps
startForDue() originally did this:
$gap = (int)((strtotime($due) - strtotime($start)) / 86400);
return gmdate('Y-m-d', strtotime($due) - $gap * 86400);
Two faults in three lines:
strtotime()reads a bare date as LOCAL midnight;gmdate()writes UTC. East of Greenwich the answer landed a day early โ and correctly in midwinter, so the fault was invisible for five months of the year and live for the other seven.- Dividing by 86400 truncates. Fourteen days across a clock change is fourteen days and an hour, or minus an hour, so the gap became 13.
The two cancelled for some inputs, which is why fixing either one alone would have exposed the other. Both are pinned by tests, and the test comments say which was real and which is a guard.
๐ Rule: a bare
Y-m-dis a plain calendar date. Parse it in UTC, do the arithmetic withDateTimeImmutable, format with->format(). Never mixstrtotime()withgmdate().
6. Idempotency
runDue() catches up rather than skipping, capped at 24 occurrences per series per run. Two guards make that safe:
if (self::occurrenceExistsFor($conn, $rid, $due)) { $id = null; }
This is not theoretical. A series is seeded with next_due_date set to the date after the task it was created on, but a re-save re-seeds it, and the worker may then be looking at a date that already has a task. Without the check the first run produces a duplicate of the task in front of you. The same guard covers the worker running twice โ two cron entries, or somebody running it by hand after it has already fired.
Each new occurrence is copied from latestOccurrence(), not from the master, so an edit to the series carries forward rather than resurrecting whatever the first one said a year ago.
7. Projections โ dates without rows
project(PDO $conn, string $from, string $to, int $capPerSeries = 60) returns the dates a fixed schedule will land on, creating nothing. api/tasks/projected.php serves them and tasks-calendar.js merges them into placedTasks() โ the same placement, span and week-wrapping as real tasks, so they cannot drift โ drawn as a faded dashed outline rather than a solid chip.
Only schedule series are projected. A completion-mode series has no predictable future: its next date is counted from the day somebody finishes, which has not happened, so projecting it would be inventing information and it would move every time somebody was a day late.
Two traps in the filtering:
- A projection has no row, so it cannot be filtered by the same SQL joins. The endpoint returns the assignee and the filter is applied client-side โ a projection shown under "My tasks" that is not yours is a promise about somebody else's week.
- The analyst id comes from
body[data-analyst-id]. Reaching forwindow.ANALYST_IDโ whichtasks.jsdefines and the calendar page does not โ would have made every projection vanish under "My tasks" and looked like the feature simply not working.
8. One home for input sanitising
TaskRecurrence::ruleFromInput(array $in): array is shared by save_recurrence.php and recurrence_preview.php.
๐ Two endpoints sanitising the same input differently is how a preview ends up showing dates the worker will not produce. A preview that lies is worse than no preview.
For the same reason the preview dates are computed server-side. Doing the maths in JavaScript would be faster and is the obvious temptation; the two implementations would drift.
Note that clamping is not rejection: nth_weekday: 0 becomes 1, it does not fail. That is fine because both endpoints do it, so preview and save agree.
9. previewRule()
public static function previewRule(array $rule, array $task, int $limit = 25): array
// โ ['occurrences' => [['n', 'due_date', 'start_date', 'first'], ...], 'truncated' => bool]
Occurrence 1 is the task in front of you and carries its own dates rather than computed ones โ the series is seeded from its due date and it counts towards max_occurrences. Subsequent entries walk nextDate() and use the same isExhausted() accounting the worker uses, so the list stops exactly where the series will.
recurrence_preview.php then marks entries that already exist by matching due_date against tasks with that recurrence_id, so previewing an established series shows what it has done as well as what it will do.
A completion-mode rule returns one entry, and the endpoint reports mode so the browser can explain rather than list.
10. Date engine edges
Both of these are places a naive implementation drops a month:
dayOfMonth()clamps. The 31st in February gives 28 or 29, not a skipped month.-1means the last day.nthWeekdayOf()falls back to the last. The fifth Tuesday of a month with four gives the fourth, not nothing.
โ ๏ธ A test for the second one is easy to write so that it passes for the wrong reason. The first attempt used "the fifth Tuesday of September 2026" โ which has five Tuesdays. Use a weekday that genuinely has four, and prove the test bites by disconnecting the fallback.
11. Testing
php tests/task-recurrence-dates.php # 60 assertions, no database
php tests/task-recurrence-spawn.php # 21 assertions, touches the database
runDue() takes an optional list of recurrence ids:
TaskRecurrence::runDue($conn, null, 24, [$ruleId]);
๐ด This exists because the tests need it. Unscoped, a test calling
runDue()operates on every series in the database โ which on a developer machine is the developer's real tasks. It created a real occurrence of a real series while the start-date fix was being tested. It also made the catch-up assertion something an unrelated series could satisfy. The cron passes nothing and is unaffected.
The spawn tests prefix everything ZZREC and clean up in a finally, including on failure. A quick check that they are behaving: the total task count should be identical before and after a run.
12. Things that look like bugs and are not
| Symptom | Cause |
|---|---|
| Setting "repeat 5 times" shows one task | Correct. A fixed schedule creates each occurrence when its start arrives; the other four are drawn faintly on the calendar |
| Completing a task produces nothing | The series is in schedule mode, where completion is not the trigger |
| Running the cron produces nothing | Nothing is ready to start yet. Use Preview, or check next_due_date against the source task's gap |
| "5" produced only 4 new tasks | The count includes the task the rule was created on |
| The board does not show next month's occurrence | Deliberate โ see ยง4 |