Skip to content

Time On Tasks Developer Guide

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

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.


1. The request, and what it actually asked for

"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.


2. Scheduled work

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 0

These 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.


3. Time entries

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_active

entry_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.

The totals

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.


4. The tasks_time_scope setting

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 in createTimeEntry, 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.

5. The panel and the large window

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 inside renderDetailPanel() 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.


6. Traps this feature walked into

Recorded because each cost real time and none of them announced itself.

A const is not a property of window

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.

An entity inside a translated string gets escaped

'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.

A <label> is styled for field names

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.

toISOString() on a bare date

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.

Never identify a status by its name

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.


7. πŸ“ Files

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

Already existed, and was reused rather than rebuilt

.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

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally