-
Notifications
You must be signed in to change notification settings - Fork 15
Time Tracking Developer Guide
Two switches, per company, over an install-wide default β and the settings layer built to carry them, which is the part worth reusing.
The user-facing page is Time tracking. Built for discussion #72.
| File | Role |
|---|---|
includes/tenant_settings.php |
tenantSetting(), tenantSettingOn(), setTenantSetting(), tenantSettingsForKey() β per-company answers falling back to install-wide. Plus the two time-tracking helpers |
tenant_settings table |
(tenant_id, setting_key, setting_value), unique on the pair, FK to tenants
|
| File | Role |
|---|---|
tickets/settings/manifest.php |
Registers the Time tracking tab under Cap::TICKETS_MANAGE
|
tickets/settings/index.php |
The tab: two switches, a per-company table, and the failed-load guard |
api/tickets/get_time_tracking_settings.php |
The default plus every company's override |
api/tickets/save_time_tracking_settings.php |
Writes both; null for a company deletes its override |
includes/settings_keys.php |
Registers the two install-wide keys against Tickets |
| File | Role |
|---|---|
api/tickets/get_time_entries.php |
Returns {disabled: true} and no rows |
api/tickets/save_time_entry.php |
Refuses the write |
api/v1/resources/tickets.php |
apiRequireTimeTracking() on list, create and delete |
assets/js/inbox.js |
Renders nothing when the server says disabled |
Every surface time tracking has is a ticket surface: the panel in the reading pane and the two endpoints behind it. There is no time report, no time menu, no dashboard widget.
So dschipfel's "remove time tracking menus, conceal related statistics and reports" was asking for less than it sounds β those do not exist β and nothing about the feature is install-wide in character. It belongs on the settings page of the module it lives in.
FreeITSM had no way to answer a per-company yes/no question before this:
-
system_settingsis a flat key/value table for the whole install -
getTenantConfigRows()handles per-company lookup lists (statuses, ticket types)
Neither can express "this company bills for time and that one does not".
tenant_settings this company's answer, if it has been given one
β falls back to
system_settings the install-wide default
β falls back to
the caller's default
tenantSettingOn($conn, $tenantId, 'time_tracking_enabled', true);
setTenantSetting($conn, $tenantId, 'time_tracking_enabled', '0');
setTenantSetting($conn, $tenantId, 'time_tracking_enabled', null); // back to the default
β οΈ A COMPANY WITH NO ROW IS NOT "OFF". It follows the install default. That distinction is doing three jobs: it keeps the whole thing invisible at N=1, it makes the default meaningful, and it is whysetTenantSetting(β¦, null)deletes the row rather than storing an empty string. Without a way back, a company given an answer once could never follow the default again.
This is deliberately generic rather than a time-tracking flag with a table around it. "Per company, defaulting to the install-wide value" is the shape most settings take once an install has more than one company in it β expect the next one to reuse this rather than invent a second mechanism.
Both were "the obvious design needs a subsystem FreeITSM does not have". The difference is size and reuse: a generic per-object ACL would have been bigger than the feature it served and is needed by exactly one thing. This is one small table with a three-step fallback, and it is the natural home for every per-company setting that follows.
Hiding a panel is about interface clutter. Silently emptying an API endpoint breaks an integration belonging to somebody who changed nothing β a billing export, a reporting job, something built months ago by a person who has left.
Different decisions, different people affected, so they are different switches. An install can tidy its screens without breaking an export it has forgotten about.
timeTrackingUiOn($conn, ticketTenantId($conn, $ticketId)); // the panel
timeTrackingApiOn($conn, ticketTenantId($conn, $ticketId)); // the REST endpointsBoth resolve from the ticket's company, because that is the only sensible unit β see below.
The usual difficulty with per-company settings is "what does an analyst working across two companies see?"
Here it evaporates: every surface hangs off a ticket, and a ticket always belongs to a company. The panel appears on one client's ticket and not the next, which is correct rather than confusing.
Had there been a cross-company time report β one screen, many companies, one answer needed β this would have been a genuinely awkward feature. It is worth noticing when a design is easy because of the shape of what already exists, rather than because you were clever.
Hiding the panel is not turning a feature off. An endpoint that still answers is one URL away from putting the panel back, and a stale tab should not go on filing time into something that is supposed to be disabled.
| Where | Behaviour |
|---|---|
get_time_entries.php |
{success: true, time_entries: [], disabled: true} |
save_time_entry.php |
Refused, with a message naming the reason |
| REST list / create / delete | 404, not an empty list |
β οΈ The REST endpoints refuse rather than returning nothing, deliberately. An empty list says "this ticket has no time recorded" β a different statement, and an untrue one. A caller that has to tell "off" from "none" cannot, if you answer both the same way.
Nothing anywhere deletes a row. ticket_time_entries is untouched by either switch; both decide what is shown and what is served.
Both switches are drawn unticked in the markup and filled in from the server afterwards. So a failed load renders exactly like time tracking being disabled β and pressing Save then writes that guess back as fact.
That is not hypothetical. The first version of this tab fetched API_BASE + 'tickets/β¦' when API_BASE already ended in api/tickets/. It 404'd, the catch swallowed it, and the tab showed both switches off while time recording worked perfectly. Ed spotted it within minutes of it shipping.
The same failure had been fixed on the Authentication page earlier the same day, with a warning written about exactly this β and then reproduced eight hours later by the person who wrote the warning.
The remedy, both times:
- a banner saying the settings could not be read
- the switches and Save disabled until real values arrive
- Save refusing outright if the load never succeeded
π If a settings screen renders a boolean before it knows the answer, the unloaded state must not be a valid-looking answer. Either show a loading state, or lock the controls β but never let "we do not know" render identically to "no".
- Register the install-wide key in
includes/settings_keys.php(module, capability, tab). - Read it with
tenantSettingOn($conn, $tenantId, 'your_key', $default). - Resolve the tenant from whatever the setting is about β for a ticket,
ticketTenantId(). - Write it with
setTenantSetting(), passingnullto mean "follow the default". - Enforce it server-side, wherever the feature actually happens β not only in the browser.
Step 5 is the one that gets skipped.
- Time tracking β the user-facing guide
- Multi-tenancy β companies, and the filters this sits beside
- Multi-tenancy: settings, global vs per-company β the wider question this answers one case of
- REST API: Tickets β the endpoints the API switch governs
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
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- 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)