-
Notifications
You must be signed in to change notification settings - Fork 17
Time On Tasks Developer Guide
How a task records when the work is planned for and how long it actually took, why those are two different features rather than one, and the traps in the way.
Built for issue #112, released as updates #1262β#1267.
"It would be very helpful to have multiple time periods with date, start and end times associated with the existing (sub-)task description. And the total duration of these time periods would be ideal."
Read quickly, that is "give a subtask a start and an end". Read properly, it is several work sessions per task, with a total β the reason given is that a task cannot always be finished in one pass. That is time tracking, and FreeITSM already had it on tickets.
Both were built, because they answer different questions and a task can want both:
| Question | Kind of date | |
|---|---|---|
| Scheduled work | When is this booked in? | Naive wall clock |
| Time entries | How long did it take, and when? | Absolute instants |
That distinction is the single most important thing on this page. Getting it backwards is silent β nothing errors, the numbers are simply wrong for anyone in a different timezone, which is exactly how issue #116 happened.
Three columns on tasks, deliberately the same names and the same rules a ticket's scheduled work uses:
work_start_datetime DATETIME NULL
work_end_datetime DATETIME NULL
work_all_day TINYINT(1) NOT NULL DEFAULT 0These are naive. They are stored exactly as typed and shown exactly as stored, so a 2pm slot reads 2pm for every analyst β the same treatment change windows and PIR actuals get. Never run them through parseUTCDate/tzOpts, and never send them through inputToUTC().
TasksService::parseNaiveDateTime() enforces that at the door: a value carrying a Z or a +01:00 offset is refused, rather than quietly stripped. Accepting it would imply a conversion that is never going to happen.
The rules, all in updateTask:
- An end needs a start β a task cannot finish work it is not scheduled for.
- An end cannot precede its start.
- Clearing the start clears the whole slot, end and all-day with it. A leftover end describes a block of work that no longer exists.
createTask does not reimplement any of that. If a create carries work_start_at/work_end_at/work_all_day it routes them straight back through updateTask, so there is exactly one copy of the rules.
task_time_entries mirrors ticket_time_entries column for column. Same idea, different parent record; a second, subtly different shape is the thing that later drifts apart.
task_id, analyst_id, notes, time_spent_minutes, entry_datetime, is_activeentry_datetime is an instant, stored UTC and converted per reader. The browser sends ISO-8601 with a Z; TasksService::parseInstant() reads a zone-less string as UTC, which is correct for this field only.
The foreign key is ON DELETE CASCADE, unlike the ticket equivalent. Tasks have no trash β a delete is a delete β so leaving the time behind would orphan rows nothing could ever reach.
timeEntriesFor() returns three numbers:
total_minutes this task alone
subtask_minutes the sum of its children's entries
total_with_subtasks_minutes the number the requester asked for
One level deep, deliberately. FreeITSM has no sub-subtasks, so recursing would answer a question the data model cannot ask. If nesting is ever allowed, this is the function to change and the only one.
The UI shows the second total only when it is non-zero, so a task with no subtasks does not display the same figure twice.
A single system_settings key, tasks_time_scope, with four values: both (default), tasks, subtasks, off. Set at Tasks β Settings β Time, guarded by Cap::TASKS_TIME.
Why a setting exists at all: a top-level task and a subtask are the same record, told apart only by parent_task_id. Nothing else distinguishes them, so offering time on one and not the other can only be a stated choice.
TasksService::timeAllowedFor($conn, $parentTaskId) is the only place that rule lives. Re-deriving it at a call site is how the panel and the endpoint end up disagreeing.
Two properties worth preserving:
-
It gates writes, not just display.
assertTimeAllowed()runs increateTimeEntry, so a tab left open before the setting changed cannot go on recording time. The UI hides the form; the server refuses regardless. - Narrowing hides, it never deletes. An administrator changing a display rule must not destroy hours somebody recorded, and a task dragged under a parent keeps its time either way β it simply becomes visible again when the setting allows it.
One panel element, two shapes. #detailPanel gains an .as-modal class and the body is regrouped into two columns by applyModalLayout().
Not a second template. Two copies of this panel would be two things to keep in step, and the one nobody is looking at is the one that rots.
.tdm-layout grid: minmax(0,1fr) / 380px
.tdm-main .detail-description, .subtask-section, .comments-section
.tdm-side everything else
the title stays outside both, full width
β οΈ applyModalLayout()must run before TinyMCE and the documents panel mount. Moving a node after TinyMCE has attached tears its iframe out of the document, and it does not come back. The call order insiderenderDetailPanel()is load-bearing.
The view is a per-analyst preference, tasks_detail_view (panel | modal), read in tasks/index.php and published as window.TASK_DETAIL_VIEW β nothing should have to fetch a preference before it can open a task. It is written from two places, the header toggle and System β Preferences, and both use the same key so they cannot disagree.
Recorded because each cost real time and none of them announced itself.
ANALYST_ID is declared const at the top of tasks.js. window.ANALYST_ID is therefore undefined, and code reading it that way removes the delete button from every time entry while looking perfectly fine. Use the bare identifier, and hand it to tasks-ctx-menu.js through its config rather than reaching for window.
The same fact bites test harnesses: driving the board from a parent frame, w.tasks and w.ANALYST_ID both read undefined. Assert against the database instead β it is the authority anyway.
'total' => '· {amount}' renders as the literal text ·, because the string goes through esc() with everything else. Build separators in the markup, never in a lang file.
The first version hung the total off a <label>: 11px, uppercase, muted. It read TIME 30M β genuinely on screen, and impossible to spot as a number. It uses the ticket panel's .time-entries-header rule now. Reusing an existing component's styling would have avoided the whole thing.
The context menu's due-date shortcuts build dates from local parts. toISOString() converts to UTC first, so anyone west of Greenwich gets yesterday. Same trap as #116, different field.
Mark complete / Reopen reads the status's is_closed flag, and picks its target status the same way. An installation that renamed Done could otherwise never complete a task from the menu β the lesson from issue #88.
| File | Role |
|---|---|
includes/services/tasks.php |
Scheduling rules, time-entry CRUD, totals, timeScope/timeAllowedFor, both date parsers |
api/tasks/get_time_entries.php |
Entries plus the three totals, and whether the panel may show the form |
api/tasks/save_time_entry.php, delete_time_entry.php
|
Create and soft-delete |
api/tasks/save.php |
Unchanged β the work fields flow through saveTask
|
api/tasks/get_settings.php, save_settings.php
|
time_scope, with a value whitelist |
tasks/settings/manifest.php |
The Time tab and Cap::TASKS_TIME
|
assets/js/tasks.js |
Panel fields, the time section, the modal layout, the view toggle |
assets/js/tasks-ctx-menu.js |
The twelve-item card menu |
system/preferences/index.php |
tasks_detail_view |
.time-entry-* CSS in inbox.css
|
The ticket time panel's rules. Only two had no equivalent and are in tasks.css
|
ticket_time_entries |
The shape task_time_entries copies |
| The ticket scheduling columns | The names and rules tasks copies |
copyToClipboard() |
Never navigator.clipboard β undefined outside a secure context, and it throws synchronously so a .catch() never runs |
- Tasks β the plain-language version
- Timezones and time handling β the three kinds of stored date
- Issue #116 β what happens when the two kinds are confused
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ 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: 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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)