Repository navigation
Recurring 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).
| 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 |
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).
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): voidIt 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().
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 itA 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.
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().
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.
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.
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.
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.
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.
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.
| 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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96