Skip to content

Recurring Tasks Developer Guide

Ed Mozley edited this page Aug 30, 2026 · 1 revision

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 task
  • recurrence_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 status
  • moveTask() β€” 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 on next_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:

  1. 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.
  2. 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-d is a plain calendar date. Parse it in UTC, do the arithmetic with DateTimeImmutable, format with ->format(). Never mix strtotime() with gmdate().


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 for window.ANALYST_ID β€” which tasks.js defines 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. -1 means 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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally