Repository navigation
Projects Internals 4 Plan and Timeline
How a project's plan works in the Projects module: the time boxes (phases, stages and sprints - one table, read through the methodology), the work (ordinary Tasks-module tasks carrying project_id and project_stage_id), milestones (named dates whose state is worked out, never stored), the Timeline tab that draws all three against the calendar and lets a mouse move them, task dependencies and the critical path (a forward and backward pass over the dated tasks), estimates and the Capacity page, the Plan tab's Waiting on N and Clash chips, and what goes on the shared Calendar. Milestones, the Timeline, dependencies, estimates and Capacity arrived in 3.3.0. The service methods named here are listed with their rules on page 3; this page explains how the plan behaves and why.
Pages in this series
- 1 Architecture
- 2 Schema
- 3 Services and API
- 4 Plan, Timeline and dependencies
- 5 Health and charts
- 6 Governance
- 7 Alerts, reports and AI
- 8 Connections, templates, people
- 9 Demo data, testing and traps
- 10 Contractors
- The files
- Stages, phases and sprints: one table
- Project tasks are ordinary tasks
- The Plan tab
- Milestones
- The Timeline
- Dependencies and the critical path
- Estimates and Capacity
- The shared Calendar
| File | What it does for the plan |
|---|---|
includes/projects/methodologies.php |
projectMethodologies() (each method's timebox and single_active), projectStageKinds(), projectStageStatuses(), projectsSchemaReady()
|
includes/services/projects.php |
saveStage(), deleteStage(), reorderStages(), applyMethodology(), createTaskInProject(), assignTask(), milestoneReached()
|
includes/services/project_tools.php |
saveMilestone(), deleteMilestone(), setTaskDates(), setTaskEstimate(), addTaskDependency(), removeTaskDependency()
|
includes/projects/read.php |
projectDetail() (stages with counts, top-level tasks with estimates and logged time, effort), projectEstimatesReady()
|
includes/projects/milestones.php |
projectMilestonesReady(), projectMilestoneState(), projectMilestones(), projectMilestoneStats()
|
includes/projects/dependencies.php |
projectDependenciesReady(), projectDependencies(), projectDependencyMakesCycle(), projectDependencyAnalysis()
|
includes/projects/capacity.php, api/projects/capacity.php, projects/capacity.php, assets/js/projects-capacity.js
|
Capacity: projectCapacity(), projectCapacityWorkdays(), projectCapacityShiftHours()
|
includes/projects/calendar.php |
projectSyncCalendar(), projectSyncCalendarKind()
|
includes/projects/alerts.php |
projectAlertsStagesDue(), projectAlertsMilestones(), projectMilestoneEventFields() (page 7) |
api/projects/stage_save.php, stage_delete.php, stage_reorder.php, task_create.php, task_assign.php, tools.php
|
the plan's endpoints (page 3) |
assets/js/projects-view.js |
the Plan tab: lanes, task rows, the estimate box, the chips, drag between lanes, the stage dialog |
assets/js/projects-milestones.js |
window.PrjMilestones - the Overview panel, the Plan's chips and strip, #prjMilestoneModal
|
assets/js/projects-timeline.js |
window.PrjTimeline - the Timeline tab, drawArrows(), the critical-path toggle, #prjDepModal
|
api/tasks/list.php, api/tasks/get.php, assets/js/tasks.js, tasks/index.php
|
the Tasks board side - search Projects (3.2.0)
|
project_stages holds every time box, whatever the method calls it: kind (phase / stage / sprint), name (150), goal (500), start_date, end_date, position, status (planned / active / closed), the gate columns (gate_decision, gate_notes, gate_decided_by, gate_decided_datetime) and, 3.3.0, gate_kind (standard / golive).
π One table, because they are the same thing seen through different methods. That is what makes switching method safe: nothing has to be converted.
// includes/projects/methodologies.php
'simple' => [..., 'timebox' => 'phase', 'single_active' => false, 'tools' => ['people']],
'staged' => [..., 'timebox' => 'stage', 'single_active' => true, 'tools' => ['people', 'scope', 'raci', 'raid', 'gates', 'budget', 'control', 'benefits']],
'agile' => [..., 'timebox' => 'sprint', 'single_active' => true, 'tools' => ['people', 'scope', 'raid']],-
A new time box takes the project's preset
timeboxas itskind(saveStage()inserts$preset['timebox']; the body cannot choose). An unknown method falls back tosimple. -
single_activeis enforced insaveStage(): setting a stage toactivewhile another is active throws "Fit-out" is still active. Close it before starting another. -
Switching method (
updateProject()seesmethodologychange) callsapplyMethodology()inside the same transaction as the update:
private static function applyMethodology(PDO $conn, int $projectId, string $method): void
{
$preset = projectMethodologies()[$method] ?? null;
if (!$preset) return;
$conn->prepare("UPDATE project_stages SET kind = ?, updated_datetime = UTC_TIMESTAMP()
WHERE project_id = ? AND status <> 'closed'")->execute([$preset['timebox'], $projectId]);
// A method that allows one active time box at a time keeps the earliest
// active one and returns any others to planned, so the switch never
// leaves the project in a state its new method forbids.
if ($preset['single_active']) {
$st = $conn->prepare("SELECT id FROM project_stages WHERE project_id = ? AND status = 'active' ORDER BY position, id");
...
if (count($ids) > 1) { array_shift($ids); /* the rest -> 'planned' */ }
}
}- every time box that is not
closedgets the newkind. Closed ones keep the name they were run under - they are history; - if the new preset is
single_activeand more than one box is active, the earliest byposition, then id stays active and the rest go back toplanned.
The switch is audited as an ordinary methodology field change. The Calendar is redrawn because the stage entries' titles say Phase / Stage / Sprint from kind.
saveStage(PDO $conn, ActorContext $ctx, int $projectId, array $in): int (endpoint api/projects/stage_save.php, REST POST/PATCH /projects/{id}/stages):
-
loadForActor()+assertCanChange(); on an edit the stage must belong to the project (not_foundotherwise). -
Every field left out keeps its stored value -
name,goal,start_date,end_date,status. The Plan's Start / Finish buttons send only{project_id, id, name, status}; the Timeline's drag sends{project_id, id, name, start_date, end_date}. - A name is required (150 at most); the end may not be before the start;
statusmust be one ofprojectStageStatuses(). -
Edit: when the status changed it audits
stage_status(Fit-out: activeβFit-out: closed); becomingclosedfiresproject.stage_closed(ProjectsService::stageClosed()with no gate decision); becomingactivecallsprojectBaselineAuto(..., 'stage', $stageId)(change control, page 6). ThensyncCalendar()andafterChange(). -
Create:
position = MAX(position) + 1; auditstage_added; a stage created alreadyactiveis baselined the same way;touch(),syncCalendar(),afterChange().
$conn->prepare("UPDATE tasks SET project_stage_id = NULL WHERE project_stage_id = ?")->execute([$stageId]);
// Its milestones stay, as the whole project's - by hand, for an install whose FK failed to add.
try { $conn->prepare("UPDATE project_milestones SET stage_id = NULL WHERE stage_id = ?")->execute([$stageId]); } catch (Throwable $e) { /* not created yet */ }
// Its gate's checklist goes with the gate (3.3.0) - by hand, for an install whose FK failed to add.
try { $conn->prepare("DELETE FROM project_gate_items WHERE stage_id = ?")->execute([$stageId]); } catch (Throwable $e) { /* not created yet */ }
$conn->prepare("DELETE FROM project_stages WHERE id = ?")->execute([$stageId]);Its tasks stay in the project with no stage ("not in a phase yet"), its milestones become the whole project's, its gate checklist goes. Audit stage_removed, then touch(), syncCalendar(), afterChange().
{project_id, ids: [...]} β positions 1..n for the ids given, each matched AND project_id = ? (an id from another project is silently ignored); ids not listed keep their old position. api/projects/stage_reorder.php exists, but nothing in the UI calls it yet - stages are ordered by when they were added.
A gate decision (ProjectToolsService::decideGate()) closes a stage and starts the next planned one by position; it is the Staged method's hand-over. The rules are on page 3 Β§3.6 and the governance around it on page 6. For the plan, the facts that matter are: a planned stage cannot be decided, and only a go that closes a stage starts the next one.
π A project's work items are tasks. There is no second task system. A task belongs to a project because tasks.project_id says so, and to a time box because tasks.project_stage_id does. Projects never owns a task row: it only sets those two columns. The Tasks code that creates, edits or deletes a task is not touched.
Both columns are ON DELETE SET NULL, never CASCADE - deleting a project or a stage must not delete the work people did (and deleteProject() / deleteStage() detach by hand as well, for installs whose FKs failed). Progress counts top-level tasks only (parent_task_id IS NULL); subtasks belong to their task and never carry project_id.
Endpoint api/projects/task_create.php (the Plan's + Add a task); also used by templates, RAID follow-up actions and the demo importer.
public static function createTaskInProject(PDO $conn, ActorContext $ctx, int $projectId, ?int $stageId, array $in): int
{
$project = self::loadForActor($conn, $ctx, $projectId);
self::assertCanChange($conn, $ctx, $project);
if ($stageId !== null && $stageId > 0) {
$s = self::loadStage($conn, $stageId);
if ((int)$s['project_id'] !== $projectId) throw new ServiceError('validation', 'invalid_field', 'That stage is not part of this project.');
} else {
$stageId = null;
}
require_once __DIR__ . '/tasks.php';
unset($in['id'], $in['ticket_id'], $in['parent_task_id']);
$in['tenant_id'] = isMultiTenant($conn)
? ($project['tenant_id'] === null ? (int)getDefaultTenantId($conn) : (int)$project['tenant_id'])
: null;
$res = TasksService::saveTask($conn, $ctx, $in);
$taskId = (int)$res['id'];
$conn->prepare("UPDATE tasks SET tenant_id = ?, project_id = ?, project_stage_id = ? WHERE id = ?")
->execute([$project['tenant_id'], $projectId, $stageId, $taskId]);
self::touch($conn, $projectId);
return $taskId;
}- Loads the project through
loadForActor(), checksassertCanChange(), and checks the stage belongs to it. - Strips
id,ticket_idandparent_task_id, and overwritestenant_idwith the project's company - a project task's company and links are the project's to decide. -
Creates the task with
TasksService::saveTask()- the one create path - so the assigned email, the bell, thetask.created/task.assignedworkflow events and everything else that happens for a new task happens here too, naming the project's company. Passing the company explicitly fixed events that named the analyst's active company when the two differed. - Only then sets
tenant_id(as stored, NULL for Default),project_idandproject_stage_idwith anUPDATE.
task_create.php passes on only title, description, assigned_analyst_id, assigned_team_id, start_date, due_date, priority_id, status_id. Tasks access is not required - being allowed to change the project is enough.
π Do not insert task rows directly from Projects. A hand-written INSERT would silently skip notifications and workflows, and the next change to task creation would have to be made twice.
// POST api/projects/task_create.php
{"project_id": 42, "stage_id": 118, "title": "Book the removal firm", "assigned_analyst_id": 7, "due_date": "2026-10-23"}
// 200
{"success": true, "id": 5540}Endpoint api/projects/task_assign.php {task_id, project_id | null, stage_id?}, used by drag between lanes (moveTask()), the Γ on a task row (removeTask(), project_id: null), and the Project field in the task window (setTaskProject()).
- a subtask cannot be put into a project (it goes with its parent);
- the task must be in the caller's company scope (
assertScope()- Task not found. otherwise); - the caller must be allowed to change the project the task is leaving (a
forbiddenthere is thrown; anot_foundfor a project they cannot see is ignored) and the one it is joining; -
project_idnull (or<= 0) takes it out of the project and its stage; - otherwise the project must be reachable and in the same company as the task (
sameTenant(), NULL read as Default - That task belongs to a different company from the project.), and a stage must belong to that project.
The task window always sends stage_id: null, so choosing a project there files the task under "not in a phase yet". A subtask is refused when put into a project (A subtask goes with its parent task - put the parent in the project., #2242): it belongs to its parent's project through its parent. Before #2242 only the UI enforced this (it offers the field on top-level tasks only); the API did not refuse. Taking a subtask out (project_id null) is still allowed, so a stray one can be tidied.
ProjectToolsService::setTaskDates() (the Timeline's drag, tools.php task_dates) and setTaskEstimate() (the Plan's estimate box, task_estimate) both write through TasksService::saveTask(), the one task write path, so the Tasks board, task calendar sync and task events follow. Only that the task is in this project and that the caller may change the project is checked:
$st = $conn->prepare("SELECT id FROM tasks WHERE id = ? AND project_id = ?");
$st->execute([$taskId, $projectId]);
if (!$st->fetchColumn()) throw new ServiceError('not_found', 'not_found', 'That task is not part of this project.');
$upd = ['id' => $taskId];
foreach (['start_date', 'due_date'] as $f) if (array_key_exists($f, $in)) $upd[$f] = self::date($in[$f]);
if (count($upd) === 1) return;
...
TasksService::saveTask($conn, $ctx, $upd);
ProjectsService::touchProject($conn, $projectId);
ProjectsService::afterChange($conn, $projectId); // an overdue task moves health-
api/tasks/list.phpaddsproject_id,project_name,project_colour(resolved to a hex byprojectColourHex()) andproject_stage_namein a separate query after the main one, not a join in it. The project is named even to analysts without the Projects module: it is a fact about a task they can already see. -
api/tasks/get.phpdoes the same for one task (the task window's "Part of" line). -
assets/js/tasks.js: the card chip (.task-card-project), the sidebar Project filter (refreshProjectFilter()builds it from the loaded tasks - nothing to fetch; All projects, No project, then each project; hidden when no task on the board belongs to a project, so an install that does not use Projects sees no change), andprojectFieldHtml()/setTaskProject()in the task window. -
tasks/index.phpsetswindow.TASK_CAN_PROJECTSfromgetAnalystAllowedModules(). Only analysts who can open Projects get the picker (loadProjectChoices()readsapi/projects/list.php, refused to everyone else); others see the project as plain text. The picker offers live projects plus the task's own if it has since finished. - The Project field is offered on top-level tasks only (
${!task.parent_task_id ? projectFieldHtml(task) : ''}). -
estimate_hours(3.3.0) is a Tasks column - see Β§8.
π An install that has pulled 3.2.0 but not yet run Database Verification has no tasks.project_id. A Tasks query naming that column would take the whole Tasks board down - a module the person never chose to upgrade. So every caller outside the Projects module asks projectsSchemaReady() first and simply leaves projects out:
function projectsSchemaReady(PDO $conn): bool
{
static $ready = null;
if ($ready !== null) return $ready;
try {
$st = $conn->query("SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'project_id'");
$ready = (int)$st->fetchColumn() === 1
&& (int)$conn->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'projects'")->fetchColumn() === 1;
} catch (Throwable $e) {
$ready = false;
}
return $ready;
}That is also why the project data is a second query in api/tasks/list.php rather than a join: the main query never names the new column. The 3.3.0 additions follow the same idea with cheaper probes (SELECT ... LIMIT 0 in a try): projectEstimatesReady() (tasks.estimate_hours), projectMilestonesReady(), projectDependenciesReady(). Every read guarded by one returns empty, and every write refuses with Run Database Verification first.
renderPlan() in assets/js/projects-view.js draws one lane per stage, in position order, then an "unassigned" lane for tasks with no stage. Everything comes from the one get.php call (page 3 Β§6); after any change the page calls refresh() and redraws every tab.
-
Lane head (
lane()): the kind word, name, goal, the stage's milestone chips (PrjMilestones.chips(stage.id)), dates, the status pill,done / total, the lane's effort once anything is estimated or logged (12h est. - 7.5h logged), and Start (planned) or Finish (active) - both astage_save.phpwith just the new status. Finishing a stage firescheer()βP.celebrate()(nothing but the toast underprefers-reduced-motion: reduce). -
Above the lanes: the whole project's milestones (
PrjMilestones.projectStrip()), the + Milestone button for those who may change the project, and + Stage / + Phase / + Sprint (timebox.add_<kind>). -
Task rows (
taskRow()): status dot, title linking totasks/?task=N- the task window is the one place a task is edited, the project page never grows a second editor - subtask count, priority pill (only when it is not the default priority), the estimate cell, the dependency chips, the due date (red when late), the assignee's or team's initials, and Γ (take it out of the project). -
Add a task: each lane has a light + Add a task that opens a small form (title, assignee, due date) posting to
task_create.php. The open lane is remembered across redraws, so several tasks can be added in a row. -
Drag between lanes moves a task to another stage (
task_assign.php). Rows aredraggableonly whenpermissions.can_change. -
Read-only:
.prj-readonlyon the page (set frompermissions.can_change) hides the Plan's write controls inassets/css/projects.css; the server refuses either way.
// assets/js/projects-view.js - taskRow()
+ ((t.waiting_on || []).length && !closed
? '<span class="prj-task-wait" title="' + esc(T('deps.waiting_tip', { names: t.waiting_on.map(id => ((data.tasks.find(x => x.id === id) || {}).title || '#' + id)).join(', ') })) + '">'
+ esc(T('deps.waiting', { count: t.waiting_on.length })) + '</span>' : '')
+ (t.clash && !closed ? '<span class="prj-task-clash" title="' + esc(T('deps.clash')) + '">' + esc(T('deps.clash_short')) + '</span>' : '')| Chip | Shown when | String |
|---|---|---|
| Waiting on N | the task is open and waiting_on (from projectDependencyAnalysis(), Β§7) is not empty - it waits for N tasks that are not finished. The tooltip names them |
deps.waiting Waiting on {count}, deps.waiting_tip Waiting for: {names}
|
| Clash | the task is open and clash is true - it is planned to start on or before the day a task it waits for is due to finish (plus the lag), so the plan cannot be true as written |
deps.clash_short Clash, deps.clash (the tooltip) |
The page decides nothing: both flags arrive on each task from get.php. The same waiting_on counts the "blocked by a dependency" figure in the Overview's status breakdown (assets/js/projects-insights.js).
estimateCell(): for somebody who may change the project, a number box (min 0, max 9999, step 0.25) holding estimate_hours; otherwise the value as text. Time logged (the task and its subtasks, from logged_minutes) shows beside it, red once it is over the estimate. Leaving the box or pressing Enter posts tools.php task_estimate; after the redraw, focus goes back to the box that was being tabbed into, so a column of estimates can be typed in one go.
A milestone is a named date, not work: "Move day", "Go-live", "Board sign-off". The work is tasks; a milestone is what they are racing towards. It may sit in a stage (drawn in that stage's band and lane) or belong to the whole project (stage_id NULL).
project_milestones: project_id, stage_id (NULL = the whole project), name (150), due_date (NOT NULL), done_date (set = reached), done_by_analyst_id, notes (500), position, created_by_analyst_id, is_demo. Four FKs: project CASCADE, stage SET NULL (and deleteStage() does it by hand), the two analysts SET NULL.
/** done | missed | due - see the header. */
function projectMilestoneState(array $m, ?string $today = null): string
{
if (!empty($m['done_date'])) return 'done';
return $m['due_date'] < ($today ?? gmdate('Y-m-d')) ? 'missed' : 'due';
}| State | When | Extra |
|---|---|---|
done |
done_date is set |
met = done_date <= due_date (reached late is still done, not missed) |
missed |
not done and due_date before today (UTC) |
|
due |
not done, today or later |
Storing it would let it go stale overnight with no write to update it. Every reader calls the one function: projectMilestones() (the page: every milestone with stage_name, done_by_name, state, met, ordered by due_date, position, id), projectMilestoneStats() (per project: missed count and next {id, name, due_date} - the earliest not done from today, for health and the portfolio card), api/projects/portfolio_charts.php, the assistants (projectAssistOverview(), projectAssistDates()), and the event payload projectMilestoneEventFields().
Health. projectDecorate() takes milestones_missed from projectMilestoneStats(). A missed milestone turns automatic health amber by default - the same weight as an overdue task. The setting project_health_milestones (Health tab: off, amber - the default - or red) changes that; only with red can a missed milestone make a project red on its own. See page 5.
tools.php milestone_save {id?, name?, due_date?, stage_id?, notes?, done?, done_date?}:
$name = array_key_exists('name', $in) ? trim((string)$in['name']) : (string)($cur['name'] ?? '');
if ($name === '') throw new ServiceError('validation', 'missing_field', 'Give the milestone a name.');
$due = array_key_exists('due_date', $in) ? self::date($in['due_date']) : ($cur['due_date'] ?? null);
if ($due === null) throw new ServiceError('validation', 'missing_field', 'Give the milestone a date.');
...
if (array_key_exists('done', $in)) {
if (!empty($in['done'])) {
$doneDate = self::date($in['done_date'] ?? null) ?? ($doneDate ?: gmdate('Y-m-d'));
if (empty($cur['done_date'])) $doneBy = $ctx->actorId > 0 ? $ctx->actorId : null;
} else {
$doneDate = null; $doneBy = null;
}
}
if ($doneDate !== null && $doneDate > gmdate('Y-m-d')) throw new ServiceError('validation', 'invalid_field', 'A milestone cannot be reached in the future.');-
Any field left out keeps its value, so the Timeline can send just
{id, due_date}. -
done: truestampsdone_date(the one given, else the existing one, else today) and who - who only when it was not already done;done: falseclears both. -
stage_idmust belong to the project. - Audit:
milestone_added; on an editmilestone_moved(Move day: 2026-11-21βMove day: 2026-11-28),milestone_reached,milestone_reopened. - Then
touchProject(),syncCalendar()(the Calendar shows milestones not yet reached),project.milestone_reachedwhendone_datewent from empty to set (ProjectsService::milestoneReached(), withlate_days), andafterChange()(a missed milestone moves health).
deleteMilestone() audits milestone_removed, redraws the Calendar and runs the scan.
| Event | From | Rule |
|---|---|---|
project.milestone_reached |
saveMilestone() |
payload milestone + late_days (0 when on time); bell off by default |
project.milestone_due |
projectAlertsMilestones() in the scan |
windows projectStageDueWindows() = [7, 1]; only the nearest window that applies fires; ledger key project_milestone:<id>:<window>, fingerprint the due date; payload days_remaining, window_days
|
project.milestone_missed |
the scan | once, ledger key project_milestone:<id>:missed, fingerprint the due date; only for milestones due in the last 14 days - one added with a date already long gone is not news; payload days_late
|
Moving a milestone changes the fingerprint, so both re-arm. Only live projects (proposed, active) are scanned. Notification rule 3 (coalescing) folds a project's due and missed bells into one unread row. Details on page 7.
window.PrjMilestones draws milestones in three places and owns the one dialog:
| Function | Where |
|---|---|
render(ctx) β renderOverview()
|
#pvMilestones on the Overview, which renderOverview() in projects-view.js leaves empty: missed first, then the next four due, then the last two reached, each with Done for those who may change. Hidden when there are none and the project is finished or read-only |
chips(stageId) |
a lane head's diamonds on the Plan |
projectStrip() |
the whole project's milestones above the lanes |
diamond(m, size), phrase(m)
|
the shared mark (ms-done / ms-missed / ms-due) and Reached on time / Reached 3 day(s) late / Missed - 5 day(s) ago / Today / Tomorrow / In 12 days
|
open(m, defaults) |
#prjMilestoneModal (ids pms*): name, date, stage (or Whole project), notes, a Reached box with its date (max today) |
save(body) |
tools.php milestone_save with a partial body, then refresh(); a newly reached one celebrates and toasts |
PrjMilestones.render() runs before renderPlan() in renderAll(), because the lane heads call chips(). The JS never decides a state - it draws the server's state, so the page, health and the bell always agree.
assets/js/projects-timeline.js - window.PrjTimeline, the #timeline tab (#pvTimeline). Each time box is a band across its dates, its tasks are bars from start to due date, milestones are diamonds - in their stage's band, or on a Project milestones row of their own. A line marks today and a dashed one the target finish.
The layout needs the panel's width, so it cannot draw into a hidden tab:
function render(c) {
if (c) ctx = c;
dirty = true;
const box = document.getElementById('pvTimeline');
if (!box || box.hidden || !ctx) return; // drawn when the tab is shown - it needs a width
draw(box);
}
window.PrjTimeline = {
render: render,
/** The tab was shown: draw if anything changed while it was hidden. */
shown: function () { if (dirty) render(); },
};renderAll() calls PrjTimeline.render(toolCtx) after showTab(), and showTab('timeline') calls shown(). A window resize redraws after 150 ms when the tab is visible.
const ROW_H = 32, BAND_H = 36, HEAD_H = 50;
const ZOOMS = { days: { minW: 26, pad: 3 }, weeks: { minW: 11, pad: 7 }, months: { minW: 4, pad: 14 } };
// ---- Day arithmetic (UTC whole days) ----
const dayNum = s => Math.floor(Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)) / 86400000);
const ymd = n => new Date(n * 86400000).toISOString().slice(0, 10);-
Days are counted in UTC (
dayNum), so a bar never shifts by one when the browser's time zone is ahead of or behind the server's. -
The span is every date on the page - today, the project's start and target, every stage's start and end, every task's span, every milestone - padded by the zoom's
paddays each side. Weeks start the view on a Monday and months on the 1st, so the ticks line up.originDayis the first column. -
Day width
dayW = max(zoom.minW, available / span): the chart fills the panel, but never narrower thanminWpixels a day (then it scrolls sideways). The label column is 230 px, 124 px at 768 px wide or less. -
x(n) = (n - originDay) * dayW. -
Header: month labels (
Intl.DateTimeFormatin the page's language,timeZone: 'UTC'), and under them day numbers (Days, weekends shaded) or Mondays (Weeks). Month labels areposition: stickybeside the name column (--lw); the month box isoverflow: clip, nothidden, or it would become the sticky's scroll container. -
Rows: the project-milestone row if any; then per stage a band row (a
<button>label that opens the stage dialog) followed by its tasks; then a Not in a phase band (timeline.unassigned) with the loose tasks. A task with no dates gets a row with no bar, and a hint counts them (timeline.undated). -
Task span (
taskSpan()): start and due; a task with only one of them is one day; reversed dates are swapped. Bars are coloured by task status (--bc),.done/.late/.crit/.dim. -
Zoom (
days,weeks,months) and the critical-path toggle are remembered in localStorage (prjTimelineZoom,prjTimelineCrit), each read and written insidetry. A redraw keeps the scroll position; a new zoom starts at today.
π Touch scrolls, it never drags. A hand-rolled drag on a phone fights the sideways scroll the chart needs (Mobile-Friendly Techniques Β§22). The drag uses pointer events with pointerType === 'mouse' only; a finger that scrolls gets pointercancel, so scrolling never opens anything, and a pointerup with no drag opens the thing pressed instead - the task (tasks/?task=N), the stage dialog, or the milestone dialog. The drag is a shortcut, never the only way to change a date.
function onDown(e) {
if (e.pointerType !== 'mouse' || e.button !== 0 || !canChange()) return;
const el = e.target.closest('[data-tl-task], [data-tl-stage], [data-tl-ms]');
if (!el || !el.closest('#pvTimeline')) return;
const h = e.target.closest('[data-h]');
drag = { el: el, startX: e.clientX, moved: false, delta: 0,
mode: el.dataset.tlMs ? 'ms' : (h ? 'resize-' + h.dataset.h : 'move'),
left: parseFloat(el.style.left), width: el.offsetWidth };
el.setPointerCapture(e.pointerId);
...
}- A move of more than 3 px is a drag; the element snaps to whole days (
Math.round(dx / dayW)). -
Modes:
move(bar or band),resize-l/resize-r(the end handles[data-h], drawn only for those who may change),ms(a diamond). -
On drop, one ordinary save through the server's rules, then
refresh()and a toast; on an error a toast and a redraw back to where it was:
| Dragged | Request |
|---|---|
| a task bar |
tools.php {action: 'task_dates', project_id, task_id, start_date, due_date} β ProjectToolsService::setTaskDates() β TasksService::saveTask(). A deadline-only task (no start) moved stays deadline-only (only due_date is sent); stretching it gives it a start |
| a stage band |
stage_save.php {project_id, id, name, start_date, end_date}
|
| a diamond |
PrjMilestones.save({id, due_date}) - the partial body |
There is no keyboard way to move a bar; the dialogs are the keyboard path.
Drawn after the HTML is in place, because it measures where the bars actually landed:
function drawArrows(box, LW) {
const deps = ctx.data.dependencies || [];
const body = box.querySelector('.prj-tl-body');
if (!deps.length || !body) return;
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'prj-tl-arrows');
svg.setAttribute('width', body.scrollWidth);
svg.setAttribute('height', body.scrollHeight);
svg.setAttribute('aria-hidden', 'true');
// two <marker>s: #prjArrow and #prjArrowBad
const pos = id => {
const bar = body.querySelector('[data-tl-task="' + id + '"]');
if (!bar) return null;
const row = bar.closest('.prj-tl-row');
const l = LW + bar.offsetLeft;
return { l: l, r: l + bar.offsetWidth, y: row.offsetTop + row.offsetHeight / 2 };
};
deps.forEach(dp => {
const a = pos(dp.depends_on_id), b = pos(dp.task_id);
if (!a || !b) return; // an undated task has no bar: no arrow
const bad = !!t.clash;
const hot = crit && t.critical && (byId[dp.depends_on_id] || {}).critical;
const x1 = a.r, y1 = a.y, x2 = b.l - 1, y2 = b.y;
let d;
if (x2 - x1 >= 14) d = 'M' + x1 + ' ' + y1 + ' H' + (x1 + 7) + ' V' + y2 + ' H' + x2;
else { const mid = y1 + (y2 > y1 ? 16 : -16); d = 'M' + x1 + ' ' + y1 + ' H' + (x1 + 7) + ' V' + mid + ' H' + (x2 - 10) + ' V' + y2 + ' H' + x2; }
...
});
body.appendChild(svg);
}- From the right end of the task waited for to the left end of the one that waits - finish-to-start, as the data means.
- An elbow: out 7 px, down (or up) to the waiter's row, across. When there is less than 14 px of room (the waiter starts before or just after the other ends) it detours: out, 16 px down, back left past the waiter's start, down, in.
-
Red and dashed (
.bad,#prjArrowBad) where the waiter clashes; hot where the critical-path toggle is on and both ends are critical. - One SVG over the grid and under the bars' text,
aria-hidden, and never in the way of a click. The dependencies are also listed in words in the dialog, which is the accessible path.
[data-tl-crit] flips crit, stores it, and redraws: bars with t.critical get .crit, the rest .dim, arrows between two critical tasks get .hot. The flag comes from the server (Β§7) - the JS computes nothing.
Each task row's label cell is a container: the .prj-tl-name link and a .prj-tl-dep link-icon button (with a count when it has dependencies, .clash when it clashes). The button shows for those who may change the plan, or when there are dependencies to read. openDeps(taskId) fills #pdpBody:
- the clash warning, if any;
-
It waits for - each predecessor with its lag (+2 days) and Γ to remove (
dep_remove); - for those who may change: a select of the project's other tasks (not itself, not ones already chosen), a Gap (days) number (-365..365) and Add (
dep_add); - Waiting for it - the tasks that wait for this one;
- an error line: the server's refusal (a loop) shows here.
depCall() posts, refreshes, and reopens the dialog on the same task.
CREATE TABLE IF NOT EXISTS `task_dependencies` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL, -- the one that waits
`depends_on_id` INT NOT NULL, -- the one it waits for
`lag_days` INT NOT NULL DEFAULT 0,
`created_by_id` INT NULL,
`created_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_demo` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_tdep_pair` (`task_id`, `depends_on_id`),
KEY `ix_tdep_on` (`depends_on_id`),
CONSTRAINT `fk_tdep_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tdep_on` FOREIGN KEY (`depends_on_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE
);A task table, not a project one: a dependency is between two tasks. It is finish-to-start only: task_id cannot start until depends_on_id has finished, plus lag_days (which may be negative - an overlap). Everything else - waiting, clashes, the critical path - is worked out on read, never stored.
$st = $conn->prepare("SELECT d.id, d.task_id, d.depends_on_id, d.lag_days
FROM task_dependencies d
JOIN tasks a ON a.id = d.task_id AND a.project_id = ?
JOIN tasks b ON b.id = d.depends_on_id AND b.project_id = ?
ORDER BY d.id");Both ends inside the project. Both are checked when a dependency is made; a task later moved out of the project simply drops out on read (the row stays, and comes back if the task returns).
tools.php dep_add {task_id, depends_on_id, lag_days?}:
-
changeable();projectDependenciesReady()ornot_ready. - Both ids given and different (A task cannot wait for itself.).
- Both tasks are in this project (Both tasks must be in this project.).
-
lag_daysblank = 0; otherwise^-?\d{1,3}$and at most 365 either way. - No loop:
/** Would task -> dependsOn close a loop (dependsOn already waits, directly or not, for task)? */
function projectDependencyMakesCycle(array $deps, int $taskId, int $dependsOnId): bool
{
$preds = [];
foreach ($deps as $d) $preds[$d['task_id']][] = $d['depends_on_id'];
$stack = [$dependsOnId]; $seen = [];
while ($stack) {
$n = array_pop($stack);
if ($n === $taskId) return true;
if (isset($seen[$n])) continue;
$seen[$n] = true;
foreach ($preds[$n] ?? [] as $p) $stack[] = $p;
}
return false;
}A depth-first walk up the predecessors of the task about to be waited for: if it reaches the task that would wait, the new edge would close a loop. Refused with the names: That would make a loop: "Move day" already waits for "Book the removal firm".
-
INSERT ... ON DUPLICATE KEY UPDATE lag_days- adding the same pair again just changes the lag. Auditdependency_addedaswaiter <- waited for. Returns the dependency's id.
removeTaskDependency() (dep_remove {id}) only removes one that projectDependencies($pid) lists; audit dependency_removed. Dependencies are carried by templates, naming tasks by their place in the plan (page 8).
api/projects/get.php runs it over the page's top-level tasks and adds the result to each:
$detail = projectDetail($conn, $row);
$deps = projectDependencies($conn, $pid);
$an = projectDependencyAnalysis($detail['tasks'], $deps);
foreach ($detail['tasks'] as &$tk) $tk += $an[(int)$tk['id']] ?? ['depends_on' => [], 'waiting_on' => [], 'clash' => false, 'critical' => false, 'slack' => null];| Key | Meaning |
|---|---|
depends_on |
[{id, lag}] - what it waits for |
waiting_on |
[ids] - predecessors not finished, when the task itself is open |
clash |
it is planned to start on or before the day a predecessor (plus the lag) is due to finish |
critical |
on the critical path: open, dated, and no slack |
slack |
days it could slip without moving the latest finish; null for an undated task |
Step 1 - days. Every task becomes whole UTC day numbers s and e: start and due; a task with only one of them is one day long; reversed dates are swapped; a task with neither is undated (null).
$day = fn(?string $s) => $s ? (int)floor(strtotime(substr($s, 0, 10) . ' 00:00:00 UTC') / 86400) : null;
foreach ($tasks as $t) {
$s = $day($t['start_date'] ?? null) ?? $day($t['due_date'] ?? null);
$e = $day($t['due_date'] ?? null) ?? $s;
if ($s !== null && $e !== null && $s > $e) [$s, $e] = [$e, $s];
$byId[(int)$t['id']] = ['s' => $s, 'e' => $e, 'closed' => !empty($t['is_closed']) && (int)$t['is_closed'] === 1];
}Step 2 - waiting and clashes, per dependency whose both ends are on the page:
$out[$a]['depends_on'][] = ['id' => $b, 'lag' => $d['lag_days']];
if (!$byId[$a]['closed'] && !$byId[$b]['closed']) $out[$a]['waiting_on'][] = $b;
if ($byId[$a]['s'] !== null && $byId[$b]['e'] !== null && $byId[$a]['s'] <= $byId[$b]['e'] + $d['lag_days']) $out[$a]['clash'] = true;Note the <=: finish-to-start means the waiter may start the day after the other is due (plus the lag). Starting on the same day is a clash.
Step 3 - topological order over the dated tasks only (Kahn's algorithm): count each task's dated predecessors, start a queue with the tasks that have none, and repeatedly take one off and release its successors. If the order does not contain every dated task, a loop slipped in (they are refused on write, but a loop through tasks moved between projects is possible), and the function returns no critical path rather than a wrong one.
Step 4 - the forward pass (earliest start / finish):
foreach ($order as $id) {
$d = $dated[$id]['e'] - $dated[$id]['s']; // length in days, less one
$start = $dated[$id]['s'];
foreach ($preds[$id] ?? [] as [$p, $lag]) if (isset($ef[$p])) $start = max($start, $ef[$p] + $lag + 1);
$es[$id] = $start; $ef[$id] = $start + $d;
}
$finish = max($ef);A task's earliest start is the later of its own planned start and every predecessor's earliest finish + lag + 1. Its own start is a floor: the pass never pulls a task earlier than planned. $finish is the latest earliest-finish of any dated task - the plan's own end, not the project's target date.
Step 5 - the backward pass (latest finish / start), in reverse order:
foreach (array_reverse($order) as $id) {
$late = $finish;
foreach ($succs[$id] ?? [] as [$s, $lag]) if (isset($ls[$s])) $late = min($late, $ls[$s] - $lag - 1);
$lf[$id] = $late; $ls[$id] = $late - ($dated[$id]['e'] - $dated[$id]['s']);
}
foreach ($order as $id) {
$out[$id]['slack'] = $ls[$id] - $es[$id];
$out[$id]['critical'] = !$dated[$id]['closed'] && $out[$id]['slack'] <= 0;
}A task's latest finish is the plan's finish, or earlier if a successor's latest start (less the lag, less one day) forces it. Slack = latest start - earliest start. A task is critical when it is open and has no slack. A closed task takes part in the passes (it still occupies its dates) but is never marked critical.
Four tasks, day 0 = Monday 2 November 2026:
| Task | Planned |
s..e
|
Length d
|
Waits for |
|---|---|---|---|---|
| A Survey the floor | Mon 2 - Tue 3 | 0..1 | 1 | - |
| B Order the cabling | Wed 4 - Thu 5 | 2..3 | 1 | A |
| C Book the lift | Wed 4 | 2..2 | 0 | A |
| D Install the cabling | Fri 6 - Mon 9 | 4..7 | 3 | B, C |
Kahn order: A (no predecessors), then B and C, then D.
Forward:
| Task | ES = max(own start, pred EF + lag + 1) | EF = ES + d |
|---|---|---|
| A | 0 | 1 |
| B | max(2, 1+0+1 = 2) = 2 | 3 |
| C | max(2, 2) = 2 | 2 |
| D | max(4, B: 3+1 = 4, C: 2+1 = 3) = 4 | 7 |
$finish = 7.
Backward (D, C, B, A):
| Task | LF = min(finish, succ LS - lag - 1) | LS = LF - d | Slack = LS - ES | Critical |
|---|---|---|---|---|
| D | 7 (no successors) | 4 | 0 | yes |
| C | min(7, 4-0-1 = 3) = 3 | 3 | 1 | no |
| B | min(7, 3) = 3 | 2 | 0 | yes |
| A | min(7, B: 2-1 = 1, C: 3-1 = 2) = 1 | 0 | 0 | yes |
The critical path is A β B β D. Booking the lift could slip one day (to Thursday) without moving anything. No task clashes: B and C start on day 2, the day after A's day 1; D starts on day 4, the day after B's day 3.
Now three changes, one at a time:
-
C planned for Tuesday 3 instead.
s(C)= 1 β€e(A)+ 0 = 1, so C clashes - its chip says Clash and its arrow is red. The forward pass still treats it as starting on day 2 (max(1, 2)), so the critical path is unchanged. -
A 2-day lag on B β D (the cable needs two days to arrive). D's ES becomes max(4, 3+2+1 = 6) = 6, EF 9, and D's planned start 4 β€
e(B)+ 2 = 5, so D clashes. The finish moves to day 9; the critical path is still A β B β D. -
D planned for Tuesday 10 - Thursday 12 instead (
s= 8,e= 10). ES(D) = max(8, 4) = 8, EF = 10, and the finish is 10. Walking back: LF(B) = LS(D) - 1 = 7, LS(B) = 6, slack 6 - 2 = 4; LF(C) = 7, LS(C) = 7, slack 5; LF(A) = min(LS(B) - 1, LS(C) - 1) = 5, LS(A) = 4, slack 4. Only D is critical. The days are calendar days, not working days: a gap the planner left (a weekend, a holiday, or simply a late start) shows as slack.
While A is open, B, C and D are all Waiting on 1 (or 2 for D, while B and C are both open).
The same analysis feeds the AI project manager: since #2241 projectAiFacts() (includes/projects/ai.php) states each project's dependencies - which tasks are waiting, which clash, and the critical path - alongside the open gate checklist items and the stakeholder map, so a briefing or report can name them. Details on page 7.
Dependencies are finish-to-start only (no start-to-start or finish-to-finish), within one project, not in the REST API, and they do not move dates by themselves - a clash is shown, never fixed for you. Days are calendar days.
tasks.estimate_hours DECIMAL(7,2) NULL is a Tasks column, written only through TasksService:
private static function parseEstimate($v): ?float
{
if ($v === null || (is_string($v) && trim($v) === '')) return null;
if (!is_numeric($v) || (float)$v <= 0 || (float)$v > 9999) {
throw new ServiceError('validation', 'invalid_field', "'estimate_hours' must be a number of hours above 0 and at most 9999.");
}
return round((float)$v, 2);
}On create it is a separate UPDATE after the insert, so an install that has not verified the column still creates tasks. Four ways in: the task window (assets/js/tasks.js, under the dates), the REST API (estimate_hours on every task, accepted on create and PATCH), the Plan's box (tools.php task_estimate β ProjectToolsService::setTaskEstimate() β TasksService::saveTask()), and templates (estimate_hours per task).
projectDetail() adds to every task estimate_hours and logged_minutes - active task_time_entries on the task and its subtasks - and to the project effort:
$project['effort'] = ['estimate_hours' => round($est, 2), 'estimated_tasks' => $estCount, 'logged_hours' => round($logged / 60, 2), 'tasks' => count($tasks)];The Overview shows it under the progress figure (effort.tile: 212.5h estimated - 140.3h logged), each lane its own, each task row its own. projectEstimatesReady() guards every read (NULL AS estimate_hours before Verification). The burn-up can count hours instead of tasks (project_burnup_measure, page 5), and the budget forecast prices the hours still to come (page 6).
projectCapacity(PDO $conn, int $viewerId, int $weeks = 4): array (includes/projects/capacity.php), read-only and worked out per request, like health. Endpoint api/projects/capacity.php?weeks=4|8|12 (anything else is 4; the function itself accepts 1-12). Page projects/capacity.php (nav capacity, body[data-mobile-page="projects-capacity"]), drawn by assets/js/projects-capacity.js.
Per analyst, per week (Monday to Sunday, from this week):
| Input | Rule |
|---|---|
| Projects |
proposed / active, scoped with activeTenantReadFilter() (the portfolio's scope) |
| Members-only (3.3.0) | the work still counts - a person's load is real - but a project the viewer may not see is named only as projects.visibility.hidden_name
|
| Work | open top-level tasks in those projects with an analyst or a team. Remaining = estimate_hours - logged (task and subtasks), floored at 0 |
| No estimate | counted per person (no_estimate, a ?n flag), not added - unknown work is a risk, not zero |
| Estimate used up, still open | state spent: shown, counted as nothing left |
| No due date |
unscheduled hours, beside the weeks |
| Due before today | all of it in this week (late) |
| Otherwise | spread evenly over the working days from max(start_date ?? today, today) to due_date (no working day in between: all on the due date); a day past the window adds to later
|
| Team work | open tasks with a team and no analyst β team_tasks / team_hours, in nobody's row - real work nobody has picked up |
| Desk |
ticket_rota_entries Γ the shift's length (projectCapacityShiftHours(): an end at or before the start runs past midnight). Only for a viewer who can open Tickets, and only with project_capacity_desk on (an on-call rota is not time away from projects) |
| Tickets |
tickets.owner_id - the assignee, not assigned_analyst_id - not deleted, status not closed, scoped by allAccessibleTenantsFilter() (every company the viewer can see). Context, not hours: a ticket has no size. Only with Tickets access |
| People | assignees plus analyst members of those projects (so somebody on a project with nothing assigned shows as free); inactive analysts dropped |
| Load |
round((project + desk) Γ 100 / project_capacity_hours): red over 100, amber at or above project_capacity_amber, else green. several = open work on at least project_capacity_projects live projects |
/** The working days from $from to $to inclusive, as Y-m-d. $days: ISO weekdays (1 = Monday), Monday to Friday by default. */
function projectCapacityWorkdays(string $from, string $to, array $days = [1, 2, 3, 4, 5]): array
/** Length of a rota shift in hours; a shift that ends at or before it starts runs past midnight. */
function projectCapacityShiftHours(string $start, string $end): floatA worked line: a 12-hour task, 4 hours already logged, starting Wednesday and due the following Tuesday, Monday to Friday working: 8 hours left over Wed, Thu, Fri, Mon, Tue = 1.6 hours a day - 4.8 in this week, 3.2 in the next.
Settings (Health tab, Cap::PROJECTS_HEALTH):
| Key | Default | Rule |
|---|---|---|
project_capacity_hours |
37.5 |
num:1:80 (two decimals) - hours a person works in a week |
project_capacity_amber |
85 |
int:50:100 - load % that is stretched |
project_capacity_projects |
3 |
int:2:20 - live projects at once that is "several" |
project_capacity_days |
1,2,3,4,5 |
weekdays - ISO days the hours are spread over |
project_capacity_desk |
1 |
bool - rota shifts count as time away |
The payload:
{
"success": true,
"capacity": {
"weeks": [{"start": "2026-10-05", "end": "2026-10-11"}, {"start": "2026-10-12", "end": "2026-10-18"}],
"hours_per_week": 37.5, "amber": 85, "several": 3, "can_tickets": true, "count_desk": true,
"estimates_ready": true, "team_hours": 6.0, "team_tasks": 2,
"rows": [
{
"analyst_id": 7, "name": "Sam Patel", "worst_pct": 112, "several": false,
"weeks": [{"project": 30.2, "desk": 12.0, "load_pct": 112, "state": "red"}, {"project": 18.0, "desk": 0.0, "load_pct": 48, "state": "green"}],
"projects": [{"id": 42, "code": "PRJ-0042", "name": "Leeds office move"}],
"unscheduled": 4.0, "no_estimate": 1, "later": 2.5, "late": 1, "open_tickets": 6,
"tasks": [{"id": 5531, "title": "Patch panels labelled", "project_id": 42, "project_code": "PRJ-0042", "project_name": "Leeds office move",
"due_date": "2026-10-21", "estimate_hours": 6.0, "remaining_hours": 4.5, "late": false, "state": "scheduled", "weeks": [0, 4.5]}]
}
]
}
}Rows sort by the worst week, then name. The page: tiles (over capacity this week, stretched, on several projects, no estimate / team work), a table of people Γ weeks (each cell a bar of project hours with the desk share drawn on it, coloured by state, the tooltip project / desk / of hours), live projects, open tickets (with Tickets access) and unscheduled hours; a row opens (click, Enter or Space) to the tasks behind the numbers with their hours per week. The week count is kept in localStorage (prjCapacityWeeks). The capacity cells use status colours always paired with a percentage.
Not built: per-person working hours or leave (one setting for everybody; the rota is the only time away it knows), estimates split between a task's collaborators, capacity in the REST API or the MCP server.
includes/projects/calendar.php puts the plan's dates on the shared Calendar as all-day calendar_events, drawn by the same machinery as warranties, software renewals and Domains (see Warranty and Lease Alerts - Developer Guide) rather than a new one.
source |
Rows | Title |
|---|---|---|
project_end |
projects.target_end_date of every live project |
Project due: NAME (PRJ-0042) |
project_stage |
project_stages.end_date of every stage not closed, on those projects |
Stage ends: STAGE - NAME (Phase / Sprint from kind) |
project_milestone (3.3.0)
|
project_milestones.due_date of every milestone not reached, on those projects |
Milestone: MILESTONE - NAME |
Each entry's description says it was generated and where to change it (Change the target end date on the project to move this., closing the stage removes it, marking it reached removes it).
Which projects. Live ones only:
$live = "p.status IN ('proposed', 'active')";
// The Calendar is shared: a members-only project's dates never go on it (3.3.0).
require_once __DIR__ . '/visibility.php';
if (projectVisibilityReady($conn)) $live .= " AND p.visibility <> 'members'";- A closed or cancelled project's dates are history. On hold drops a project's entries on purpose: its dates are known to be wrong until it restarts.
- π A members-only project's dates never go on the Calendar (3.3.0). Calendar entries have no company and no audience, so every Calendar user would see them; the one safe answer for a project only its team may see is to leave it off.
β οΈ For every other project: calendar entries have no company, as with every generated kind - every Calendar user on a multi-company install sees every project's dates, as they already see contract and warranty dates. The Projects help page says so.- The titles are stored rows, so they are English, as the warranty and Domains entries are. The category name is the part an operator can change.
What is drawn - project_calendar (General tab, default all):
| Value | Draws |
|---|---|
off |
nothing |
ends |
target end dates only |
all |
target end dates, stage ends and milestones |
How. projectSyncCalendarKind(), once per source:
function projectSyncCalendarKind(PDO $conn, string $source, bool $on, string $sql, callable $title, callable $description): int
{
awcAdoptExistingCategory($conn, $source); // BEFORE the delete - see its comment
$conn->prepare("DELETE FROM calendar_events WHERE source = ?")->execute([$source]);
if (!$on) return 0;
$cat = awcEnsureCategory($conn, $source, 'Projects', '#f43f5e'); // the module's coral
$ins = $conn->prepare("INSERT INTO calendar_events (title, description, category_id, start_datetime, end_datetime, all_day, created_by, source)
VALUES (?, ?, ?, ?, ?, 1, 0, ?)");
...
}-
Clear and redraw, per
source. Each kind deletes only its own marker and inserts again, so a hand-typed entry is never touched. The clearing happens even when a kind is off - that is what makes switching it off remove the entries. -
One category, remembered by id. Every kind calls
awcEnsureCategory($conn, $source, 'Projects', '#f43f5e'): the first finds or makes Projects, the others find it by name, and each records the id as<source>_category_id.awcAdoptExistingCategory()runs before the delete - the entries about to be cleared are the only record of which category they were in - so a renamed category keeps being used. -
A full resync, not a per-project one.
calendar_eventshas no reference column to find one project's rows by, and projects are few. - The whole thing returns early (
Schema not ready) beforecalendar_events.sourceandproject_stages.end_dateexist, and never throws: a calendar out of step must never stop a project being saved.
When. ProjectsService::syncCalendar() (never throws; called after the write, outside any transaction) runs after createProject(), updateProject(), deleteProject(), saveStage(), deleteStage(), decideProposal(), ProjectToolsService::decideGate() when it closes a stage, saveMilestone(), deleteMilestone(), decideChangeRequest() when it moved the target date, at the end of createFromTemplate() (its stages are inserted after createProject() drew the end date) and in its cleanup(). Saving project_calendar in api/projects/settings.php redraws at once. It is not redrawn after a demo import (no generated kind is); the next project save does it.
Not built: a link from a Calendar entry back to its project (calendar_events has no reference column).
See also: Projects Β· Tasks Β· Mobile-Friendly Projects Β· Warranty and Lease Alerts - Developer Guide
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π Display order (modules, Watchtower cards) β Developer Guide
- 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: Files
- β³ π 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: Analysts
- β³ πΊοΈ 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
- β³ π Attachment size limits and large emails
- β³ 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
- β³ π Leases
- β³ π 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
- ποΈ Files
- 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
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ 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