Skip to content

Projects Internals 4 Plan and Timeline

Ed Mozley edited this page Oct 9, 2026 · 2 revisions

πŸ—“οΈ Projects internals 4 - Plan, Timeline and dependencies

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


Contents

  1. The files
  2. Stages, phases and sprints: one table
  3. Project tasks are ordinary tasks
  4. The Plan tab
  5. Milestones
  6. The Timeline
  7. Dependencies and the critical path
  8. Estimates and Capacity
  9. The shared Calendar

1. The files

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)

2. Stages, phases and sprints: one table

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.

The methodology lens

// 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 timebox as its kind (saveStage() inserts $preset['timebox']; the body cannot choose). An unknown method falls back to simple.
  • single_active is enforced in saveStage(): setting a stage to active while another is active throws "Fit-out" is still active. Close it before starting another.
  • Switching method (updateProject() sees methodology change) calls applyMethodology() 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' */ }
    }
}
  1. every time box that is not closed gets the new kind. Closed ones keep the name they were run under - they are history;
  2. if the new preset is single_active and more than one box is active, the earliest by position, then id stays active and the rest go back to planned.

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() - create or edit

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_found otherwise).
  • 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; status must be one of projectStageStatuses().
  • Edit: when the status changed it audits stage_status (Fit-out: active β†’ Fit-out: closed); becoming closed fires project.stage_closed (ProjectsService::stageClosed() with no gate decision); becoming active calls projectBaselineAuto(..., 'stage', $stageId) (change control, page 6). Then syncCalendar() and afterChange().
  • Create: position = MAX(position) + 1; audit stage_added; a stage created already active is baselined the same way; touch(), syncCalendar(), afterChange().

deleteStage() - its work stays

$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().

reorderStages()

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

Gates

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.


3. Project tasks are ordinary tasks

πŸ”‘ 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.

Creating a task in a project - createTaskInProject()

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;
}
  1. Loads the project through loadForActor(), checks assertCanChange(), and checks the stage belongs to it.
  2. Strips id, ticket_id and parent_task_id, and overwrites tenant_id with the project's company - a project task's company and links are the project's to decide.
  3. Creates the task with TasksService::saveTask() - the one create path - so the assigned email, the bell, the task.created / task.assigned workflow 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.
  4. Only then sets tenant_id (as stored, NULL for Default), project_id and project_stage_id with an UPDATE.

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}

Putting a task in, moving it, taking it out - assignTask()

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 forbidden there is thrown; a not_found for a project they cannot see is ignored) and the one it is joining;
  • project_id null (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.

Moving a task's dates and estimate from the project

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

The Tasks board side

  • api/tasks/list.php adds project_id, project_name, project_colour (resolved to a hex by projectColourHex()) and project_stage_name in 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.php does 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), and projectFieldHtml() / setTaskProject() in the task window.
  • tasks/index.php sets window.TASK_CAN_PROJECTS from getAnalystAllowedModules(). Only analysts who can open Projects get the picker (loadProjectChoices() reads api/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.

projectsSchemaReady(), and why api/tasks needs it

πŸ”‘ 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.


4. The Plan tab

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 a stage_save.php with just the new status. Finishing a stage fires cheer() β†’ P.celebrate() (nothing but the toast under prefers-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 to tasks/?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 are draggable only when permissions.can_change.
  • Read-only: .prj-readonly on the page (set from permissions.can_change) hides the Plan's write controls in assets/css/projects.css; the server refuses either way.

The chips: Waiting on N and Clash

// 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).

The estimate cell

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.


5. Milestones (3.3.0)

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.

State is worked out, never stored

/** 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.

The write - saveMilestone()

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: true stamps done_date (the one given, else the existing one, else today) and who - who only when it was not already done; done: false clears both.
  • stage_id must belong to the project.
  • Audit: milestone_added; on an edit milestone_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_reached when done_date went from empty to set (ProjectsService::milestoneReached(), with late_days), and afterChange() (a missed milestone moves health).

deleteMilestone() audits milestone_removed, redraws the Calendar and runs the scan.

Events

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.

The screen - assets/js/projects-milestones.js

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.


6. The Timeline (3.3.0)

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.

Drawing only when visible

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.

Layout

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);
  1. 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.
  2. 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 pad days each side. Weeks start the view on a Monday and months on the 1st, so the ticks line up. originDay is the first column.
  3. Day width dayW = max(zoom.minW, available / span): the chart fills the panel, but never narrower than minW pixels a day (then it scrolls sideways). The label column is 230 px, 124 px at 768 px wide or less.
  4. x(n) = (n - originDay) * dayW.
  5. Header: month labels (Intl.DateTimeFormat in the page's language, timeZone: 'UTC'), and under them day numbers (Days, weekends shaded) or Mondays (Weeks). Month labels are position: sticky beside the name column (--lw); the month box is overflow: clip, not hidden, or it would become the sticky's scroll container.
  6. 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).
  7. 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.
  8. Zoom (days, weeks, months) and the critical-path toggle are remembered in localStorage (prjTimelineZoom, prjTimelineCrit), each read and written inside try. A redraw keeps the scroll position; a new zoom starts at today.

Moving things - mouse only

πŸ”‘ 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.

drawArrows() - the dependency arrows

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.

The critical-path toggle

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

The dependency dialog - #prjDepModal

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.


7. Dependencies and the critical path (3.3.0)

The table

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.

Reading them - projectDependencies()

$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).

Making one - addTaskDependency()

tools.php dep_add {task_id, depends_on_id, lag_days?}:

  1. changeable(); projectDependenciesReady() or not_ready.
  2. Both ids given and different (A task cannot wait for itself.).
  3. Both tasks are in this project (Both tasks must be in this project.).
  4. lag_days blank = 0; otherwise ^-?\d{1,3}$ and at most 365 either way.
  5. 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".

  1. INSERT ... ON DUPLICATE KEY UPDATE lag_days - adding the same pair again just changes the lag. Audit dependency_added as waiter <- 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).

Working it out - projectDependencyAnalysis($tasks, $deps)

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.

A worked example

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

Who else reads it

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.

Not built

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.


8. Estimates and Capacity

Estimates (3.3.0)

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

Capacity - who is over-committed

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): float

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


9. The shared Calendar

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_events has no reference column to find one project's rows by, and projects are few.
  • The whole thing returns early (Schema not ready) before calendar_events.source and project_stages.end_date exist, 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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally