Projects Internals 5 Health and Charts - edmozley/freeitsm GitHub Wiki
How the Projects module works out a project's progress, health and tolerance breaches on every read (and why none of it is stored), how asset targets measure live progress from Assets, where the burn-up and the cumulative flow get their history from, and how the shared chart library assets/js/projects-charts.js (window.PrjCharts) draws every chart in the module - its options, its accessibility rules and the colour tokens behind it. It ends with earned value (the formulas, the code and a worked example) and a table of "the ten charts" saying which file and function draws each one and what it reads. For the plain-language version see Projects.
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
🔑 Progress, health, exceptions, milestone state, benefit state and earned value are all computed every time something reads a project. There is no progress, auto_health or exception column on purpose. Storing any of them would let it go stale the moment somebody ticked a task on the Tasks board - and the Tasks board does not know projects exist. The header of includes/projects/read.php says it in one line:
* 🔑 PROGRESS AND HEALTH ARE WORKED OUT, NEVER STORED. Progress is the share of
* the project's top-level tasks that are in a closed status; automatic health is
* derived from dates and overdue work (projectAutoHealth). Storing either would
* let it go stale the moment somebody ticked a task on the Tasks board.The only health-shaped values that ARE stored are:
| Stored | Where | Why it is allowed |
|---|---|---|
projects.health + health_note
|
the project row | the manual override a project manager chose (auto = use the worked-out value) |
projects.alert_health / alert_exceptions
|
the project row | what the alert scan saw last, so it can fire on a change (see 7 Alerts, reports and AI) - never shown as health |
project_asset_target_snapshots |
one row per target per day | history for the targets' little burn-up line; the counts themselves are live (§7) |
project_task_flow |
one row per project, day and status | history for the cumulative flow diagram, because nothing records a status change (§9) |
Everything a screen shows goes through one pipeline, so every surface agrees:
projectListRows() / projectDetail() / rpProjectRows() / projectAlertRows() / peopleProjects() / ...
│ rows with projectExceptionColumns() in the SELECT
▼
projectTaskStats($conn, $ids) one batch of grouped queries for all the ids
▼
projectDecorate($row, $stats, projectHealthConfig($conn))
│ progress, auto_health, exceptions, shown_health, tools, code, counts
▼
the portfolio, the project page, People, the other-side panels, the alert scan,
Report Packs, Watchtower, Warbot / MCP (includes/projects/assistant.php), the REST API
projectDecorate() is called from includes/projects/read.php (portfolio and project page), includes/projects/links.php (the other side), includes/people.php, includes/projects/alerts.php, includes/projects/assistant.php, includes/report_packs/blocks_projects.php, includes/watchtower_queries.php and api/v1/resources/projects.php. A new reader of project health must go through it too, never re-derive health in its own SQL.
projectTaskStats(PDO $conn, array $projectIds): array returns [project_id => stats] for many projects at once. The core is one grouped query over top-level tasks only (subtasks belong to their task and never carry project_id):
$st = $conn->prepare(
"SELECT t.project_id,
COUNT(*) AS total,
SUM(CASE WHEN ts.is_closed = 1 THEN 1 ELSE 0 END) AS done,
SUM(CASE WHEN COALESCE(ts.is_closed, 0) = 0 AND t.due_date IS NOT NULL AND t.due_date < UTC_DATE() THEN 1 ELSE 0 END) AS overdue
FROM tasks t
LEFT JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.project_id IN ($ph) AND t.parent_task_id IS NULL
GROUP BY t.project_id");Every further part is its own query in its own try, so a table that Database Verification has not created yet can never take the task counts with it. A project with no tasks still gets an entry when any later part has something for it ($out[$pid] ?? ['total' => 0, 'done' => 0, 'overdue' => 0]).
| Key | Source | Since | Used by |
|---|---|---|---|
total, done, overdue
|
the query above (done = status is_closed = 1; overdue = not closed and due_date < UTC_DATE()) |
3.2.0 | progress, health |
tickets_7d |
project_tickets joined to tickets, created_datetime >= UTC_TIMESTAMP() - INTERVAL 7 DAY
|
3.2.0 | the go-live ticket jump (§4) |
budget |
projectBudgetTotals() (includes/projects/budget.php) when projectBudgetReady() - planned, actual, forecast, basis, measured, currency |
3.2.0 / 3.3.0 | the cost exception (§6) |
targets_health |
projectTargetsWorst(projectTargetsFor(...)) |
3.2.0 | health (§7) |
raid_overdue, raid_escalated
|
open project_raid rows: dependencies and decisions with due_date < UTC_DATE(); rows with escalated_datetime set |
3.3.0 | health, the Overview, the card |
milestones_missed, next_milestone
|
projectMilestoneStats() (includes/projects/milestones.php) |
3.3.0 | health, the card |
benefits, benefits_due
|
projectBenefitStats() (includes/projects/benefits.php) |
3.3.0 | the Overview line and the card chip - on finished projects too |
changes_pending |
projectChangeStats() (includes/projects/control.php) - requests with status = 'proposed'
|
3.3.0 | the Overview line, the portfolio chip, projectAssistWhy()
|
The RAID part, for reference:
$rq = $conn->prepare("SELECT project_id,
SUM(CASE WHEN type IN ('dependency', 'decision') AND due_date IS NOT NULL AND due_date < UTC_DATE() THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN escalated_datetime IS NOT NULL THEN 1 ELSE 0 END) AS escalated
FROM project_raid WHERE project_id IN ($ph2) AND status = 'open' GROUP BY project_id");Before Database Verification adds escalated_datetime this query throws, the catch swallows it, and the project simply has no raid_overdue - the screens work as they did in 3.2.0.
projectHealthConfig(PDO $conn): array reads Projects → Settings → Health (capability Cap::PROJECTS_HEALTH) into the shape projectAutoHealth() wants:
function projectHealthConfig(PDO $conn): array
{
return [
'amber_days' => (int)projectSetting($conn, 'project_amber_days'),
'amber_progress' => (int)projectSetting($conn, 'project_amber_progress'),
'red_overdue_pct' => (int)projectSetting($conn, 'project_red_overdue_pct'),
'ticket_amber' => (int)projectSetting($conn, 'project_ticket_amber'),
'milestones' => projectSetting($conn, 'project_health_milestones'),
'raid_late' => projectSetting($conn, 'project_health_raid_late'),
];
}The settings, from projectSettingDefinitions() in includes/projects/settings.php:
| Key | Default | Rule | Meaning |
|---|---|---|---|
project_amber_days |
14 |
int:1:120 |
amber when the target finish is this many days away or less... |
project_amber_progress |
75 |
int:1:100 |
...and less than this share (%) of the tasks is done |
project_red_overdue_pct |
25 |
int:1:100 |
red when this share (%) or more of the open work is overdue |
project_ticket_amber |
5 |
int:0:500 |
amber when this many linked tickets were raised in the last 7 days; 0 = not used |
project_health_milestones (3.3.0)
|
amber |
effect (off / amber / red) |
what a missed milestone does to worked-out health |
project_health_raid_late (3.3.0)
|
amber |
effect |
what a dependency not arrived, or a decision not made, by its due date does |
The capacity keys (project_capacity_*) live on the same tab but do not feed health - see 4 Plan, Timeline and dependencies.
projectSettings() caches per request and returns the defaults before Database Verification, so health works on a fresh upgrade. projectAutoHealth() also carries its own fallback when it is called with $cfg = null:
$cfg = $cfg ?? ['amber_days' => 14, 'amber_progress' => 75, 'red_overdue_pct' => 25, 'ticket_amber' => 5, 'milestones' => 'amber', 'raid_late' => 'amber'];projectAutoHealth(array $p, array $stats, ?array $cfg = null): ?string is checked in this order, and the first rule that matches wins (open = total - done, today is gmdate('Y-m-d')):
| # | Returns | When |
|---|---|---|
| 1 | null |
status is closed or cancelled (projectFinishedStatuses()) - a finished project has no health |
| 2 | red |
targets_health is red - an asset target past its date and unfinished (§7) |
| 3 | red |
target_end_date < today and open > 0
|
| 4 | red |
open > 0, overdue > 0 and overdue × 100 >= open × red_overdue_pct
|
| 5 | red |
a missed milestone with project_health_milestones = red, or a late dependency / decision with project_health_raid_late = red (3.3.0) |
| 6 | amber |
overdue > 0 |
| 7 | amber |
a missed milestone with project_health_milestones = amber (3.3.0) |
| 8 | amber |
raid_overdue > 0 with project_health_raid_late = amber (3.3.0) |
| 9 | amber |
projectTicketSpike() - linked tickets in 7 days >= project_ticket_amber (never red) |
| 10 | amber |
a target date, open > 0, the target amber_days away or less, total > 0, and done × 100 / total < amber_progress
|
| 11 | amber |
targets_health is amber (turns the final green amber) |
| 12 | green |
otherwise |
The code, exactly:
function projectAutoHealth(array $p, array $stats, ?array $cfg = null): ?string
{
if (in_array($p['status'], projectFinishedStatuses(), true)) return null;
// Projects -> Settings -> Health; the defaults are 14 days, 75% and 25%.
$cfg = $cfg ?? [...];
$msEffect = $cfg['milestones'] ?? 'amber';
$raidEffect = $cfg['raid_late'] ?? 'amber';
$missed = ($stats['milestones_missed'] ?? 0) > 0;
$raidLate = ($stats['raid_overdue'] ?? 0) > 0;
$total = $stats['total'] ?? 0; $done = $stats['done'] ?? 0; $overdue = $stats['overdue'] ?? 0;
$open = $total - $done;
$targets = $stats['targets_health'] ?? null;
if ($targets === 'red') return 'red';
$today = gmdate('Y-m-d');
if (!empty($p['target_end_date']) && $p['target_end_date'] < $today && $open > 0) return 'red';
if ($open > 0 && $overdue > 0 && $overdue * 100 >= $open * $cfg['red_overdue_pct']) return 'red';
// An install that treats a missed date as off track (Projects -> Settings -> Health).
if (($missed && $msEffect === 'red') || ($raidLate && $raidEffect === 'red')) return 'red';
if ($overdue > 0) return 'amber';
// A date the project promised has gone by (includes/projects/milestones.php).
if ($missed && $msEffect === 'amber') return 'amber';
// Something the plan was waiting on is late: a dependency, or a decision (3.3.0).
if ($raidLate && $raidEffect === 'amber') return 'amber';
// A jump in linked tickets - usually just after go-live - is a warning, never red.
if (projectTicketSpike($stats, $cfg)) return 'amber';
if (!empty($p['target_end_date']) && $open > 0) {
$days = (strtotime($p['target_end_date']) - strtotime($today)) / 86400;
if ($days <= $cfg['amber_days'] && $total > 0 && ($done * 100 / $total) < $cfg['amber_progress']) return 'amber';
}
return $targets === 'amber' ? 'amber' : 'green';
}Points worth knowing:
-
A milestone missed is never red on its own with the default setting - it only becomes red when an install sets
project_health_milestones = red. Rules 5, 7 and 8 are switchable because they are judgement calls: anything different teams would want differently is a setting. - The ticket jump can never make a project red - it sits after every red check. It is a go-live signal: a new system goes in, the tickets arrive.
-
A project with no tasks has
open = 0, so rules 3, 4 and 10 never fire. With no milestones, RAID dates, targets or tickets it is green. - Rule 10 measures tasks done, not time elapsed: 74% done 14 days out is amber, 75% is green.
- Then
projectDecorate()forcesredwhenever there is an exception (§6). That is not inprojectAutoHealth()itself.
/** Have this many linked tickets been raised in the last 7 days? (Projects -> Settings -> Health) */
function projectTicketSpike(array $stats, array $cfg): bool
{
$n = (int)($cfg['ticket_amber'] ?? 0);
return $n > 0 && (int)($stats['tickets_7d'] ?? 0) >= $n;
}tickets_7d has its own query and its own try in projectTaskStats(), because a missing link table must not take the task counts with it. projectDecorate() exposes tickets_7d and ticket_spike for the Overview note (gates.ticket_spike in assets/js/projects-view.js). Because every surface reads projectDecorate(), the portfolio, the alert scan (so the bell rings), Report Packs and Watchtower follow with no change of their own.
Settings at their defaults. Today is 2026-10-09. A live project, target finish 2026-10-20, 20 top-level tasks, 13 done, 2 overdue; one milestone missed; no targets, no tickets.
- Rule 3: the target is in the future - no.
- Rule 4:
open = 7;2 × 100 = 200 >= 7 × 25 = 175- red.
The same project with 1 overdue task: 100 >= 175 is false, so not red; rule 6 (overdue > 0) makes it amber. With no overdue tasks: rule 7 (the missed milestone) makes it amber. With the milestone met as well: rule 10 - 11 days to go (<= 14) and 13 × 100 / 20 = 65 < 75 - still amber. Two more tasks done (15 of 20 = 75%) and it is green.
projectAutoHealth(), in the help page (help.health.* in lang/en/projects.php, rendered by projects/help.php, whose header says so), and on the user page. Change one, change the others.
projectDecorate(array $p, array $stats, ?array $cfg = null): array takes a project row (which must already carry the exception columns, §6) and adds:
| Field | Value |
|---|---|
task_total, task_done, task_overdue
|
from the stats |
progress |
round(done × 100 / total), 0 with no tasks |
tickets_7d, ticket_spike
|
§4 |
milestones_missed, next_milestone
|
3.3.0 |
raid_overdue, raid_escalated
|
3.3.0 |
changes_pending |
3.3.0 |
benefits, benefits_due
|
3.3.0 |
auto_health |
projectAutoHealth(), then forced to red when exceptions is not empty (and the project is live) |
exceptions |
projectExceptions() (§6) |
shown_health |
null for closed / cancelled, else the manual health when it is not auto, else auto_health
|
code |
projectCode() - PRJ-0042
|
tools |
projectEnabledTools() - the method's tools after tailoring |
$p['auto_health'] = projectAutoHealth($p, $s, $cfg);
$p['exceptions'] = projectExceptions($p, $s);
if ($p['exceptions'] && $p['auto_health'] !== null) $p['auto_health'] = 'red';
$p['shown_health'] = in_array($p['status'], projectFinishedStatuses(), true)
? null
: ($p['health'] !== 'auto' ? $p['health'] : $p['auto_health']);🔑 A manual health always wins in shown_health, even over an exception. The project page still shows the exception banner, and the portfolio card still shows its Exception chip, so an override never hides a breach.
projectDecorate() uses projectTicketSpike($s, $cfg ?? ['ticket_amber' => 5]), so a caller without a config still gets the default threshold.
-
projectListRows($conn, $analystId, $f = [])- the portfolio. Company scope in SQL (activeTenantReadFilter()), members-only projects removed withprojectVisibleSql()(3.3.0), optional filtersq/status/mine; it selectsprojectPriorityColumn(),projectVisibilityColumn(),projectProposalApprovalColumn(), the active stage's name, the stage count andprojectExceptionColumns(), then callsprojectTaskStats()for every id once andprojectDecorate()per row with oneprojectHealthConfig(). Order:FIELD(p.status, 'active', 'proposed', 'on_hold', 'closed', 'cancelled'), then target finish (NULLs last), then name - the page re-sorts in the browser byproject_portfolio_sort. -
projectDetail($conn, $row)- one project for its page: the decorated row, stages withtask_total/task_done, top-level tasks (withestimate_hoursandlogged_minutesfor the task and its subtasks), the last 50 history rows, andproject.effort(estimate_hours,estimated_tasks,logged_hours,tasks).
A tolerance is how far a project may drift before it is an exception (PRINCE2's "manage by exception", in the module's own words). Tolerances are rows in project_tolerances (dimension + value); only project-level rows (stage_id NULL) are used today. The tolerances are set on the Gates tab and saved by ProjectToolsService::saveTolerances() (tools action tolerances_save):
| Dimension | Range | Means |
|---|---|---|
time |
0-365 days | days past the target finish, or past the active stage's end, with work still open |
risk |
1-25 | the highest open risk score (probability × impact) allowed |
cost (3.2.0)
|
0-500 % | how far spend may go over the planned budget |
A blank value deletes that row. The save writes a tolerances history row and calls ProjectsService::afterChange() so a breach it creates fires at once. The Gates tab only shows the cost box when the budget tool is on (renderGates() in assets/js/projects-tools.js). The project_tolerances.dimension column comment reads time | risk | cost (3.3.0).
Templates keep all three tolerances. projectTemplateNormalise() (includes/projects/templates.php) accepts time 0-365, risk 1-25 and, since #2242, cost 0-500; before #2242 a template's cost tolerance was dropped.
Rather than a second query per project, the portfolio and project queries carry five extra subquery columns:
function projectExceptionColumns(PDO $conn): string
{
if (!projectsPhase2Ready($conn)) return "NULL AS max_risk, NULL AS tol_time, NULL AS tol_risk, NULL AS tol_cost, NULL AS active_stage_end";
return "(SELECT MAX(r.probability * r.impact) FROM project_raid r WHERE r.project_id = p.id AND r.type = 'risk' AND r.status = 'open') AS max_risk,
(SELECT t.value FROM project_tolerances t WHERE t.project_id = p.id AND t.stage_id IS NULL AND t.dimension = 'time') AS tol_time,
(SELECT t.value FROM project_tolerances t WHERE t.project_id = p.id AND t.stage_id IS NULL AND t.dimension = 'risk') AS tol_risk,
(SELECT t.value FROM project_tolerances t WHERE t.project_id = p.id AND t.stage_id IS NULL AND t.dimension = 'cost') AS tol_cost,
(SELECT s.end_date FROM project_stages s WHERE s.project_id = p.id AND s.status = 'active' ORDER BY s.position, s.id LIMIT 1) AS active_stage_end";
}The table alias must be p. The RAID score is never stored - this is one of the two places that compute it (the other is ProjectToolsService::raid()).
🔑 The portfolio query names project_raid and project_tolerances in subqueries. Before Database Verification those tables do not exist, and the whole portfolio would fail - not just the new tabs. projectsPhase2Ready() probes both tables once per request (SELECT 1 ... LIMIT 0), and when either is missing the columns become NULL AS .... Every exception is then empty and the module works as phase 1 did. Any new subquery against a newer table in a shared read needs the same kind of guard.
function projectsPhase2Ready(PDO $conn): bool
{
static $ready = null;
if ($ready === null) {
try { $conn->query("SELECT 1 FROM project_tolerances LIMIT 0"); $conn->query("SELECT 1 FROM project_raid LIMIT 0"); $ready = true; }
catch (Throwable $e) { $ready = false; }
}
return $ready;
}projectExceptions(array $p, array $stats): array returns a list of breaches - only for a live project with the gates tool on. This is the one place where tailoring changes behaviour: switch Gates off and the project has no exceptions at all.
if (in_array($p['status'], projectFinishedStatuses(), true) || !in_array('gates', projectEnabledTools($p), true)) return [];kind |
Breached when | Extra fields |
|---|---|---|
time |
tol_time set, open > 0, and target_end_date is more than tol_time days in the past |
late, allowed
|
stage_time |
the same, measured from active_stage_end
|
late, allowed
|
risk |
tol_risk set and max_risk > tol_risk
|
score, allowed
|
cost |
tol_cost set, planned > 0, and spend > planned × (1 + tol_cost / 100)
|
over_pct, allowed, basis (3.3.0) |
"Spend" for the cost check is $stats['budget']['measured'] - actual or forecast, chosen by project_cost_basis (3.3.0; see 6 Governance). over_pct is floor((spent / planned - 1) × 100).
$b = $stats['budget'] ?? null;
$spent = $b ? (float)($b['measured'] ?? $b['actual']) : 0.0;
if (isset($p['tol_cost']) && $p['tol_cost'] !== null && $b && $b['planned'] > 0 && $spent > $b['planned'] * (1 + (int)$p['tol_cost'] / 100)) {
$out[] = ['kind' => 'cost', 'over_pct' => (int)floor(($spent / $b['planned'] - 1) * 100), 'allowed' => (int)$p['tol_cost'], 'basis' => $b['basis'] ?? 'actual'];
}Where it shows:
-
exceptionsBanner()inassets/js/projects-tools.js(exported asPrjTools.exceptionsBanner) draws the red banner, one line per breach fromgates.exc_<kind>with the breach's fields as parameters. The Overview (assets/js/projects-view.js) and the Gates tab both call it. - The portfolio card shows an Exception chip (
gates.exception) whenp.exceptions.length. - The alert scan fires
project.tolerance_breachedonce per kind, withkind,late_days,score,over_pct,basisandallowed(see 7 Alerts, reports and AI).
Tolerances: time 5 days, risk 12, cost 10%. Today 2026-10-09, target finish 2026-10-01, 3 tasks open; the worst open risk is probability 4 × impact 4 = 16; planned budget 10,000, actual 10,800, project_cost_basis = actual.
- time:
late = 8 > 5- breach{kind: time, late: 8, allowed: 5}. - risk:
16 > 12- breach{kind: risk, score: 16, allowed: 12}. - cost:
10,800 > 10,000 × 1.10 = 11,000? No - no cost breach. Withproject_cost_basis = forecastand a forecast of 11,500, it is a breach withover_pct = floor(15.0) = 15.
auto_health is red because of the breaches, whatever projectAutoHealth() said.
Live progress measured from Assets - "312 of 480 laptops replaced". project_asset_targets holds the rule; project_asset_target_snapshots holds one point a day (UNIQUE (target_id, snap_date)) for the target's little burn-up line. Arrived in 3.2.0.
-
Scope -
scope = 'linked'(joinproject_assets- the assets on the Connections tab) or'filter'(scope_type_idand/orscope_field LIKE %scope_value%; at least one of the two is required, so a target is never "every asset"). AlwaysCOALESCE(a.tenant_id, default) = the project's companyon a multi-company install. -
Done -
done_field+done_op+done_value, fromprojectTargetDoneFields():
done_field |
Column | Kind | Operators |
|---|---|---|---|
status |
a.asset_status_id |
list (an id) |
is, is_not
|
location |
a.location_id |
list |
is, is_not
|
operating_system |
a.operating_system |
text |
contains, not_contains, is
|
feature_release |
a.feature_release |
text |
at_least, is
|
model |
a.model |
text |
contains, not_contains, is
|
manufacturer |
a.manufacturer |
text |
contains, not_contains, is
|
bitlocker_status |
a.bitlocker_status |
text |
is, is_not
|
tpm_version |
a.tpm_version |
text |
at_least, is
|
Scope fields (projectTargetScopeFields()): model, manufacturer, operating_system, hostname, each matched with LIKE.
at_least is a string compare - ($col IS NOT NULL AND $col <> '' AND $col >= ?) - which orders 23H2 < 24H2 and 1.2 < 2.0. is / is_not with an empty value test for blank (COALESCE($col, '') = '').
🔑 The SQL is built from whitelists only. projectTargetNormalise() checks every field and operator against the two maps and throws ServiceError('validation', 'invalid_field', ...) on anything else; projectTargetSql() runs projectTargetNormalise() again on every row it is given, stored or not ("never trust a stored row to be well formed"). Column names come from the maps; every value is a bound parameter, with %, _ and \ escaped for LIKE.
/** [done, total] for one target. */
function projectTargetCount(PDO $conn, array $project, array $t): array
{
[$from, $args, $done, $dArgs] = projectTargetSql($conn, $project, $t);
$st = $conn->prepare("SELECT COUNT(*), COALESCE(SUM(CASE WHEN $done THEN 1 ELSE 0 END), 0) FROM $from");
$st->execute(array_merge($dArgs, $args));
...
}projectTargetsFor($conn, $ids) returns every target on those projects with done, total, pct (floored), due (own target_date or the project's target finish) and health (null on a closed or cancelled project). projectTaskStats() calls it and adds targets_health = projectTargetsWorst($targets), so the portfolio, the project page, People and the other-side panels all agree.
function projectTargetHealth(int $done, int $total, ?string $due, string $setOn): ?string
{
if ($total === 0) return null;
if ($done >= $total) return 'green';
$today = gmdate('Y-m-d');
if (!$due) return 'green';
if ($due < $today) return 'red';
$start = strtotime(substr($setOn, 0, 10)); $end = strtotime($due); $now = strtotime($today);
if ($end <= $start) return 'green';
$expected = max(0, min(100, ($now - $start) * 100 / ($end - $start)));
return ($done * 100 / $total) < $expected - 25 ? 'amber' : 'green';
}-
red- pastdueand unfinished; -
amber-done%more than 25 points below a straight line from the target'screated_datetimetodue; -
null- nothing in scope.
projectAutoHealth() returns red first on targets_health = red, and turns its final green amber on targets_health = amber (§4).
Worked example. A target set on 2026-09-01, due 2026-11-30 (90 days). On 2026-10-16 (45 days in) the straight line expects 50%. 480 in scope, 100 done = 20.8%. 20.8 < 50 - 25 = 25 - amber. At 130 done (27.1%) it is green.
projectTargetsDetail($conn, $projectId) - called only from api/projects/get.php - upserts today's point for every target (INSERT ... ON DUPLICATE KEY UPDATE done, total), then returns the last 120 days of points as [{d, done, total}], and done_value_name (status 6 becomes "Retired", via projectTargetValueNames()). A failed write (a read-only replica) is swallowed: the counts are still right. So a target's line only gains a point on a day somebody opened the project. Saving a changed rule deletes the target's points, because the old line measured something else.
-
Writes:
ProjectToolsService::saveTarget()/deleteTarget()-changeable()andassertAssets()(Assets module access: the rule names asset types and statuses, and the dialog's live count would otherwise tell somebody without Assets what is in the estate); the type must be oneprojectTargetOptions()offers the project's company; historytarget_saved/target_removed. -
Endpoint:
api/projects/tools.php- GETtarget_options=1,target_preview=1&<rule>({done, total}),target_assets=ID&show=left|done(at most 200, viaprojectTargetAssets()), all needing Assets; POSTtarget_save,target_delete.get.phpaddstargetsandcan_assets. -
Screen:
assets/js/projects-targets.js(PrjTargets.render) draws into#pvTargets, whichrenderOverview()leaves empty; the dialogs are#prjTargetModaland#prjTargetListModalinprojects/view.php. Phone: mobile.css LAYER 44q. Each target's line isspark(t)- a 240 × 44 inline SVG polyline ofdone / totalper point,aria-hidden(the numbers beside it carry the meaning), with "line soon" text until there are two points. It does not usePrjCharts. -
projectTargetsReady()probes both tables; before Verification every target read is empty.
TRAP:
NOT (done)for "still to do". An asset with no status makesstatus = 6NULL, andNOT NULLis NULL - the asset vanished from the "still to do" list while the count (aCASE WHEN) still counted it.projectTargetAssets()usesNOT COALESCE(($done), 0)- aTRAP:comment marks it.
There is no server-side burn-up series. burnupPoints() in assets/js/projects-view.js builds it from the tasks get.php already returns (each with created_datetime, completed_datetime, is_closed, estimate_hours):
function burnupPoints() {
const val = t => burnMeasure === 'hours' ? (t.estimate_hours !== null && t.estimate_hours !== undefined ? Number(t.estimate_hours) : 0) : 1;
const tasks = data.tasks.filter(t => !burnStage || String(t.project_stage_id || '') === burnStage);
...
let first = tasks.map(created).filter(Boolean).sort()[0] || today;
if (data.project.start_date && data.project.start_date < first) first = data.project.start_date;
...
const step = span > 120 ? 7 : 1;
...
return pts.map(d => ({ d: d,
scope: tasks.reduce((s, t) => s + (created(t) && created(t) <= d ? val(t) : 0), 0),
done: tasks.reduce((s, t) => s + (doneOn(t) && doneOn(t) <= d ? val(t) : 0), 0) }));
}-
Scope on a day = the tasks (or their estimated hours) created by that day; done = the ones completed by that day (closed with a
completed_datetime). A task moved into the project later counts from its creation - the honest reading of what is stored. - From the earlier of the project's start and its first task, to today; daily up to 120 days, weekly beyond, and the last point is always today.
-
Measure - tasks or hours. The default is the install's
project_burnup_measure(General tab,tasks/hours, sent in lookups asburnup_measure); the segmented control changes it for this page view only. -
Stage - a picker narrows it to one stage's tasks; the target line then becomes that stage's
end_date. -
View (3.3.0) - burn-up or burndown, remembered per browser in localStorage
freeitsm.projects.burnview. - The burndown is the same points as
remaining = scope - done, starting at the first point with any scope (before it, nothing was left and the ideal line would start at zero). -
renderBurnup()draws only while the Overview is showing, because the chart measures its width;showTab('overview')redraws it (andPrjInsights.draw()).
The cumulative flow diagram needs "how many tasks sat in each status on each day". Nothing in FreeITSM records when a task changes status, so the flow is snapshotted into project_task_flow (project_id, day, status_id, task_count), composite primary key (project_id, day, status_id) - declared in $primaryKeys in api/system/db_verify.php.
/** Record today's counts by status for these projects (top-level tasks). Quiet: never breaks its caller. */
function projectFlowSnapshot(PDO $conn, array $projectIds): void
{
if (!$projectIds || !projectFlowReady($conn)) return;
try {
...
$st = $conn->prepare("SELECT project_id, COALESCE(status_id, 0) AS s, COUNT(*) AS n FROM tasks
WHERE project_id IN ($ph) AND parent_task_id IS NULL GROUP BY project_id, COALESCE(status_id, 0)");
...
$conn->prepare("DELETE FROM project_task_flow WHERE day = UTC_DATE() AND project_id IN ($ph)")->execute($ids);
$ins = $conn->prepare("INSERT INTO project_task_flow (project_id, day, status_id, task_count) VALUES (?, UTC_DATE(), ?, ?)");
foreach ($rows as $r) $ins->execute([(int)$r['project_id'], (int)$r['s'], (int)$r['n']]);
} catch (Throwable $e) {
error_log('projects flow snapshot: ' . $e->getMessage());
}
}Today's rows are rewritten every time; earlier days are never touched. So the last write of a day stands for that day. A task with no status counts under status_id = 0.
When snapshots are taken:
-
api/projects/get.php- every time somebody opens the project (just beforeprojectFlow()reads it); -
projectAlertsScan()inincludes/projects/alerts.php- for one project after a change (afterChange()), or for every project with statusproposed,activeoron_holdon the hourly cron run (workflowScheduledRun()) and the portfolio's opportunistic run (at most every 15 minutes).
projectFlow(PDO $conn, array $project): array returns:
-
statuses-[{id, name, closed}]for the install's task statuses in board order (display_order, id); -
points-[{d, counts: {status_id|'open'|'done': n}, recorded: bool}]; -
from- the first recorded day, or null.
The chart starts at the earliest of the project's start_date, its first top-level task's creation date and the first recorded day; daily up to 120 days, weekly beyond (the last point is always today).
-
On or after
from- the recorded day itself, or the last recorded day before it ("carried forward": a day nobody and nothing looked at keeps the previous counts).recorded: true. - 🔑 Before the first snapshot only two things are known about a task: when it was created and when it was finished. Those days are reconstructed as two bands -
done(finished by that day) andopen(created but not finished) - withrecorded: false. The code never invents the statuses in between.
foreach ($tasks as $x) {
if (!$x['c'] || $x['c'] > $d) continue;
if ($x['f'] && $x['f'] <= $d) $done++; else $open++;
}
$points[] = ['d' => $d, 'counts' => (object)['open' => $open, 'done' => $done], 'recorded' => false];Status history therefore starts on the day 3.3.0 is installed (or the day a project is first looked at), and the chart marks that day (§11).
window.PrjCharts is plain SVG with no library, written to the dataviz method (checked 2026-10-09). The header lists the rules every chart follows:
* - Two categorical series, slots 1 and 2 (blue, orange) - validated for
* colour-blind separation and contrast on the app's own light (#fff) and
* dark (#1e2228) surfaces; the dark steps are chosen, not flipped
* (--prj-viz-1 / --prj-viz-2 in projects.css).
* - Thin marks: 2px lines, bars <= 24px with rounded data ends, 4px end dots
* with a 2px surface ring; hairline solid gridlines.
* - A legend for every two-series chart; values selectively (line ends, bar
* tips); text in text colours, never the series colour.
* - A hover layer (crosshair + one tooltip for both series on the line; per
* bar on the bars), the same on keyboard focus, and a table view - the
* tooltip never holds a value nobody can reach otherwise.
* - Names go in with textContent (they are users' words).window.PrjCharts = { burnup, burndown, bars, spend, stack, flow, milestones, lines, slot };Every function is fn(container, opts): it empties the container and redraws from scratch, so calling it again after a resize or a data change is safe. Callers must draw only when the container is visible - each chart reads plot.clientWidth (falling back to container.clientWidth, then 600) and never goes below 280px wide.
| Helper | What it does |
|---|---|
el(name, attrs, parent) |
creates an SVG element in the SVG namespace |
html(tag, cls, text) |
creates an HTML element; text goes in with textContent (never innerHTML) |
nice(max) |
a clean axis maximum and step - steps of 1, 2, 2.5, 5 or 10 × a power of ten, about four steps |
num(v) |
one decimal, toLocaleString(lang) with the page's <html lang>
|
legend(series) |
.prj-viz-legend of .prj-viz-key items - a swatch (.prj-viz-swatch + class, line for a line series) and the name |
tableView(head, rows) |
.prj-viz-table with a real <table>; columns after the first are .num (right-aligned, tabular figures); the header row is sticky |
frame(container, tableLabel, chartLabel, buildTable) |
clears the container, adds .prj-viz-tools (with a .prj-viz-toggle button) and .prj-viz-plot; the toggle builds the table on demand, hides the plot while it shows, and swaps its own label between the two words. Returns {tools, plot} - each chart inserts its legend as the first child of tools
|
tooltip(plot) |
a hidden .prj-viz-tip inside the plot |
tipRow(tip, value, name, cls) |
one tooltip line: a swatch, the value in <strong>, the series name |
slot(n) |
'viz-s' + n for 1-6, 'viz-n2' for 'n2', otherwise 'viz-n'
|
- Every SVG has
role="img"and anaria-labelthe caller writes as a sentence of the numbers ("12 of 30 done..."). -
Time charts (
burnup,burndown,spend,flow,lines) puttabindex="0"on the SVG. Focus shows the crosshair and tooltip at the current point (the last one, at first); ArrowLeft / ArrowRight move it one point; blur hides it. A transparentrectover the plot area drives the sameshow(i)frompointermove(nearest point by x) and hides onpointerleave. -
Bar charts (
bars,stack) and milestones make each bar, segment or diamond focusable (tabindex="0");pointerenterandfocusshow its tooltip,pointerleaveandblurhide it.barsuses an invisible.viz-hitrect wider than the bar so a short bar is still easy to hit. - Focus rings:
.prj-viz-svg:focus(2px accent outline),rect.viz-bar:focus,.viz-hit:focusand.viz-ms:focus(accent stroke). - The table view is the guarantee: every value a tooltip can show is also in the table, so nothing depends on hover, colour or a pointer.
- Tooltips are positioned in the plot's own pixels (the SVG's x scaled by
plot.clientWidth / W) and clamped inside the plot.
Scope and done over time, with the target finish as a reference line.
opts: {points: [{d: 'Y-m-d', scope, done}], target, series: [scopeName, doneName],
unit: '' | 'h', fmtDate, labels: {table, chart, date, target, aria}}- 230px tall; margins l 44, r 64 (room for end labels).
- The x axis runs from the first point to the later of the last point and
target. - Done is slot 1 (blue) with a 10% area under it; scope is slot 2 (orange). Both get a 4px end dot.
- End labels only when the two ends are at least 14px apart - otherwise the legend and tooltip carry them.
- Date ticks: as many as fit, about one per 110px, at most 5 - so they never collide on a phone.
- The target is a
viz-refvertical line labelled withlabels.target. - Table: date, scope, done.
The work still to do over time against an ideal line from where it stood on the first day to nothing at the target. Above the line is behind.
opts: {points: [{d, remaining}], target, unit, fmtDate, labels: {table, chart, date, remaining, ideal, target, aria}}- One series (slot 1). The ideal is
viz-line viz-ideal- the--prj-viz-n2neutral, dashed5 4, 1.5px - ink, not a series. -
ideal(d) = max(0, r0 × (1 - (d - x0) / (xEnd - x0)))wherexEndis the target (or the last day without one). - The tooltip and the table carry both remaining and ideal.
Grouped horizontal bars - two values per category.
opts: {rows: [{label, a, b, note?}], series: [aName, bName], fmt: v => text, labels: {table, chart, category, aria}}-
ais slot 1,bis slot 2. Anullvalue draws no bar and reads-in the table. - Bars are 12px with a 2px gap, square at the baseline and a 4px rounded data end; a positive value is at least 3px wide.
- The value sits at the bar's tip;
note(for example "18% over") is appended after the second bar's value. - Vertical gridlines at the
nice()steps.
Cumulative planned and actual spend as steps (money moves on the day, not in a slope between days), the forecast as a dashed continuation of actual from today to the finish, and the amounts to measure against as reference lines.
opts: {points: [{d, planned, actual|null}], forecast: {from, to, start, end}|null,
refs: [{v, label}], today, target, fmt, fmtTick, fmtDate,
series: {actual, planned, forecast}, labels: {table, chart, date, today, target, at_finish, aria}}- Actual is slot 1 with a 10% area; planned is slot 2 and carries on flat to the end of the axis (nothing more is planned after its last date). Points with
actual: null(after today) are skipped for the actual line. - The forecast is
viz-line viz-s1 viz-dash(dashed6 4) from(from, start)to(to, end), with a dot and a haloed value label at its end. -
Reference lines are dotted hairlines (
viz-ref viz-refline) labelled in text colour (viz-reflabel, with a surface-coloured halo) on the left, so they never meet the forecast's end. They are thresholds, not series, so they take no series colour. Lines closer than a label's height (13px) share one stack of labels above the topmost of them, in the same top-to-bottom order as the lines, so no label sits on another line. - A
todayline with its label when today falls inside the axis. - The crosshair stops on every day the money moved and the forecast's end; between points the tooltip reads the last point on or before that day.
- Table: date, planned, actual; then a row for the forecast at the finish and one per reference line.
Horizontal stacked bars - one row per group (a stage, a project), segments per series (task statuses).
opts: {rows: [{label, values: [n...], note?}], series: [{name, slot}],
labels: {table, chart, group, total, aria}, fmt?, max?, totalText?: (row, total) => text}- 18px bars, 2px rounded, each segment 2px shorter than its share so segments keep a surface gap.
- The row's total (or
totalText(row, total)- used for "65%") andnotesit at the row's end in text colour. -
maxfixes the scale; by default it is the largest row total, so rows are comparable. - Legend in series order; table: group, one column per series, total.
Stacked areas over time - the cumulative flow diagram.
opts: {points: [{d, values: [n...]}], series: [{name, slot}], marker?: {d, label}, fmtDate, labels: {table, chart, date, aria}}- Bands are stacked bottom-up in the order given; a band that is empty all the way along is skipped. Each band is
viz-bandwith a 1px surface-coloured stroke between bands. - The legend is the series reversed, so it reads top-to-bottom like the bands.
-
markerdraws a dotted reference line with a label; past two thirds of the way across, the label sits to the left of its line so it is never cut off. - The tooltip lists every non-zero band, top band first.
Milestones across rows - one row per project, a diamond per milestone on a shared calendar, today marked.
opts: {rows: [{label, items: [{d, name, state}]}], today, fmtDate,
labels: {table, chart, project, milestone, date, state, states: {done, missed, due}, today, aria}}- State is a status, so it is never colour alone: reached is a filled good diamond (
ms-done,#0ca30c), missed a filled critical one (ms-missed,#d03b3b), coming up an outline (ms-due, surface fill with a text-colour stroke). Its own legend says the three states in words, and the tooltip and the table say each milestone's state in words. - The axis runs from 3 days before the earliest item (or today) to 3 days after the latest.
- Table: project, milestone, date, state.
Several lines over time on one money axis - earned value.
opts: {points: [{d, values: [n|null...]}], series: [{name, slot}], refs?: [{v, label}], today?, step?,
fmt, fmtTick, fmtDate, labels: {table, chart, date, today, aria}}- Up to three series, slots 1-3 - the all-pairs-safe set. Only
viz-line/viz-dotclasses for slots 1-3 exist inprojects.css; a fourth slot would draw with no stroke colour. - A
nullvalue ends that line (earned value stops today). -
step: truedraws steps (values change on a day), asspend. - Reference lines as
spend, but without the label stacking. - The tooltip lists every series with a value at that point.
:root { --prj-viz-1: #2a78d6; --prj-viz-2: #eb6834; --prj-viz-3: #1baf7a; --prj-viz-4: #eda100; --prj-viz-5: #e87ba4; --prj-viz-6: #008300; --prj-viz-n: #b8b6ae; --prj-viz-n2: #898781; }
[data-theme="dark"] { --prj-viz-1: #3987e5; --prj-viz-2: #d95926; --prj-viz-3: #199e70; --prj-viz-4: #c98500; --prj-viz-5: #d55181; --prj-viz-6: #008300; --prj-viz-n: #4a4a47; --prj-viz-n2: #6e6d68; }| Token | Light | Dark | Role |
|---|---|---|---|
--prj-viz-1 |
#2a78d6 blue |
#3987e5 |
series 1 - done, actual, actual cost |
--prj-viz-2 |
#eb6834 orange |
#d95926 |
series 2 - scope, planned, planned value |
--prj-viz-3 |
#1baf7a aqua green |
#199e70 |
series 3 - earned value; the third status |
--prj-viz-4 |
#eda100 amber |
#c98500 |
the fourth status |
--prj-viz-5 |
#e87ba4 pink |
#d55181 |
the fifth status |
--prj-viz-6 |
#008300 green |
#008300 |
the sixth status |
--prj-viz-n |
#b8b6ae |
#4a4a47 |
neutral: "not done", "open before history", a seventh status and on |
--prj-viz-n2 |
#898781 |
#6e6d68 |
darker neutral: "finished before history", the burndown's ideal line |
- Slots 1 and 2 are validated for colour-blind separation and contrast on this app's own surfaces, light
#fffand dark#1e2228, with their own dark steps (chosen, not flipped). - Slots 3-6 (and their dark steps) were validated with 1-2 for adjacent pairs - stacks and lines - with the dataviz validator on 2026-10-09. 🔑 In light mode slots 3-5 need relief: they do not all separate from each other and from white well enough to be read from colour alone, which is why every chart has a Table view, why a stack row carries its total and notes in text, and why the legend and the tooltip name every series. Never add a chart that only colour can read.
-
viz-n/viz-n2are neutrals, never a series: they mean "not recorded", "not done" or the ideal line. - Dark mode follows the app's own theme attribute (
[data-theme="dark"], set by the user's theme choice), like the rest of FreeITSM's palettes. -
Health colours are status colours, not series colours. Green and amber sit inside the protan floor, so the health strip, the roadmap and the ring always pair them with words and counts. The milestone diamonds use fixed status colours (
#0ca30c,#d03b3b) for the same reason and always come with words. - Slot classes:
.viz-s1...viz-s6,.viz-n,.viz-n2, applied to.prj-viz-swatch,.viz-bar,.viz-band(all six slots and both neutrals), and.viz-line/.viz-dot(slots 1-3 only)..dashon a swatch draws a dashed key;.viz-dashdashes a line;.viz-end.halogives an end label a surface-coloured halo.
projects-view.js leaves an empty #pvInsights and calls PrjInsights.render({data, L, projectId}); draw() runs only when #pvOverview is visible, and showTab('overview') calls it again. Nothing is drawn for a project with no tasks.
-
Task status -
PrjCharts.stack(): a row for the whole project, one per stage, and "not in a stage" when there are stages and loose tasks. One segment per task status (d.flow.statuses, board order). Each row's note says how many are overdue and how many wait on another task (waiting_onfrom the dependency analysis). A note above says the totals. -
Progress by stage -
PrjCharts.stack()with two series, done (slot 1) and not done (slotn), andtotalTextgiving the percentage. -
Flow over time -
PrjCharts.flow()fromd.flow(§9). Bands bottom-up: closed statuses (last-first), then "finished before history" (n2) when any point is reconstructed, then open statuses (last-first), then "open before history" (n). The marker labels where real history starts (fl.from). Fewer than two points shows "the flow will appear soon" text instead.
🔑 A status keeps its colour in both charts: slotOf(i) uses its place in the board's order, never its place in a chart (colour follows the entity). Six validated slots; a seventh status and on share the neutral.
const slotOf = i => i < 6 ? i + 1 : 'n';renderBurnup() draws into #pvBurnup: PrjCharts.burnup() or PrjCharts.burndown() depending on the remembered view (§8), with the measure, stage and view controls in the panel head.
Three charts, each drawn only when #pvBudget is visible; PrjBudget.shown() (called by showTab('budget')) draws all three again.
-
Where the money goes -
drawChart()→PrjCharts.bars(): planned (a) against actual (b) per category, the lines summed per category (a currency-mismatched actual is left out) plus priced labour (b.labour.cost) added to thelabourcategory.TRAP: checking the chart box's own
offsetParentnever drew, because the box starts hidden until there is something to draw. The check is on the tab panel (#pvBudget), not the box. -
Spend over time -
drawSpend()→PrjCharts.spend()fromb.timeline(projectBudgetTimeline()). Reference lines: the budget (b.planned), the latest baseline's budget fromdata.control.baselines[0].plan.budget_plannedwhen change control is on and it differs, and the cost tolerance (planned × (1 + tol / 100)) when one is set. Money ticks areIntl.NumberFormatcompact currency. The note says how many lines have no planned date. -
Earned value -
drawEarned()→PrjCharts.lines()withstep: true: planned value (slot 2, orange - the plan, as on spend over time), earned value (slot 3, aqua) and actual cost (slot 1, blue - what was spent), actual cost cut off after today; a reference line at BAC. Four tiles above it - CPI, SPI, EV of BAC, EAC with VAC - each with a word ("under budget for the work done", "behind plan"), not just a number.
Layout charts (beside cards and roadmap; localStorage freeitsm.projects.layout) calls charts(ordered) with the projects in the view, in the view's order. Its extra data comes once from api/projects/portfolio_charts.php and is cached in chartData; resizing redraws.
api/projects/portfolio_charts.php scopes with projectListRows() (the same company and members-only rules as list.php) and returns:
| Key | Contents |
|---|---|
budgets |
{project_id: {planned, actual, forecast, currency}} from projectBudgetTotals(), projects with no budget and no actual left out - never added across currencies |
risks |
[{project_id, title, probability, impact}] - open risks with both scores |
milestones |
[{project_id, name, due_date, state}] - every milestone with projectMilestoneState()
|
The four panels:
-
Progress -
PrjCharts.stack(), done / not done per project (projects with tasks), percentage as the total. -
Budget against actual -
PrjCharts.bars(), one chart per currency (amounts in different currencies are never on one axis), sorted by actual / planned, the note "N% over" or "forecast N% over". -
Risk heat map - a 5 × 5 grid of buttons (not
PrjCharts), every open risk in view counted per cell, shaded by score (< 8low,8-14mid,>= 15high); clicking a cell lists its risks with links to each project's#raid; clicking again clears. Labels are the install's probability and impact words. -
Milestones -
PrjCharts.milestones(), one row per project that has any.
The portfolio also has the health strip (healthStrip()) - one segmented bar of live projects by shown health, role="img" with an aria-label of the counts, and a legend with words and numbers - and the roadmap (roadmap(), built from the Timeline's .prj-tl-* classes).
Earned value is worked out from what the budget and the plan already hold - nothing extra is entered.
🔴 TRAP (fixed in 3.6.0, #2320): measure against today's scope, never the scope on that date. Up to 3.5.2 the share for each past date was "done by then ÷ tasks that existed by then", so adding tasks made earned value FALL (6 of 10 done = 60%; add 10 tasks and the same work reads 30%). Earned value never falls - work done stays done. Measuring every date against today's whole scope makes the line only rise; today's figures (EV, CPI, SPI, EAC) are unchanged, because today's scope is what they always used.
| Term | Meaning | From |
|---|---|---|
| BAC | budget at completion | the planned total (projectBudgetDetail() passes $planned) |
| PV | planned value by date | the timeline's cumulative planned line |
| EV | earned value by date | BAC × the share of today's whole scope done by that date - it only ever rises |
| AC | actual cost by date | the timeline's cumulative actual line (lines and priced labour) |
| CPI | cost performance index | EV / AC - above 1 = under budget for the work done |
| SPI | schedule performance index | EV / PV - above 1 = ahead of plan |
| CV | cost variance | EV - AC |
| SV | schedule variance | EV - PV |
| EAC | estimate at completion | BAC / CPI |
| VAC | variance at completion | BAC - EAC |
function projectEarnedValue(PDO $conn, int $projectId, array $timeline, float $bac): ?array
{
if ($bac <= 0 || empty($timeline['points'])) return null;
...
$byHours = array_sum(array_map(fn($t) => (float)($t['h'] ?? 0), $tasks)) > 0;
$weight = fn($t) => $byHours ? (float)($t['h'] ?? 0) : 1.0;
// A finished task with no completed date counts as finished from when it was created.
$doneOn = fn($t) => $t['f'] ?: ((int)$t['closed'] === 1 ? $t['c'] : null);
$scope = 0.0;
foreach ($tasks as $t) $scope += $weight($t); // today's whole scope
$pct = function (string $d) use ($tasks, $weight, $doneOn, $scope) {
$done = 0.0;
foreach ($tasks as $t) {
$fin = $doneOn($t);
if ($fin && $fin <= $d) $done += $weight($t);
}
return $scope > 0 ? $done / $scope : 0.0;
};
$today = gmdate('Y-m-d');
$points = [];
foreach ($timeline['points'] as $p) {
$points[] = ['d' => $p['d'], 'pv' => $p['planned'], 'ev' => $p['d'] <= $today ? round($bac * $pct($p['d']), 2) : null, 'ac' => $p['actual']];
}
...
$ev = (float)$now['ev']; $pv = (float)$now['pv']; $ac = (float)$now['ac'];
$cpi = $ac > 0 ? round($ev / $ac, 2) : null;
$spi = $pv > 0 ? round($ev / $pv, 2) : null;
$eac = $cpi ? round($bac / $cpi, 2) : null;
return [
'bac' => round($bac, 2), 'pv' => round($pv, 2), 'ev' => round($ev, 2), 'ac' => round($ac, 2),
'cv' => round($ev - $ac, 2), 'sv' => round($ev - $pv, 2), 'cpi' => $cpi, 'spi' => $spi,
'eac' => $eac, 'vac' => $eac !== null ? round($bac - $eac, 2) : null,
'measure' => $byHours ? 'hours' : 'tasks', 'points' => $points,
];
}Details that matter:
-
The work is the project's top-level tasks. When any of them has an estimate, the share done is weighted by estimated hours (a task with no estimate then weighs 0); otherwise every task weighs 1.
measuresays which, and the page's note says so (ev_note_hours/ev_note_tasks). - Scope on a day is the tasks created by that day, so adding tasks later can make EV dip - that is the honest reading, and the same rule as the burn-up.
- A closed task with no
completed_datetimecounts as finished from its creation. -
evis null for points after today;nowis the last point on or before today - andprojectBudgetTimeline()always includes today as a point. -
Null when BAC is 0 (no planned budget), there are no timeline points, the project has no tasks, or the query fails (before
estimate_hoursexisted). -
cpiis null with nothing spent,spinull with nothing planned by today, andeac/vacnull when CPI is null or 0. ⚠️ EAC divides by the rounded CPI (two decimals), so EAC can differ slightly fromBAC × AC / EV.
projectBudgetDetail() returns it as earned (projectEarnedValue($conn, $pid, $tl, $planned)), so get.php, the REST budget and the MCP tool all carry it. projectAiFacts() (includes/projects/ai.php) adds a line with PV, EV, AC, CPI and SPI in words, and EAC.
Today is 2026-10-09. Planned budget (BAC) 10,000: a line of 4,000 planned for 2026-09-01 and one of 6,000 planned for 2026-10-15. Spent: 3,000 on 2026-09-10 and 2,000 on 2026-10-01 - AC 5,000. No labour priced. Four top-level tasks estimated at 10, 10, 20 and 40 hours (80 in all); the 10-hour and the 20-hour tasks are done.
| Working | Result | |
|---|---|---|
| PV (today) | only the 4,000 is planned by 2026-10-09 | 4,000 |
| Share done | (10 + 20) / 80 hours | 0.375 |
| EV | 10,000 × 0.375 | 3,750 |
| AC | 3,000 + 2,000 | 5,000 |
| CPI | 3,750 / 5,000 | 0.75 - over budget for the work done |
| SPI | 3,750 / 4,000 = 0.9375 | 0.94 - behind plan |
| CV | 3,750 - 5,000 | -1,250 |
| SV | 3,750 - 4,000 | -250 |
| EAC | 10,000 / 0.75 | 13,333.33 |
| VAC | 10,000 - 13,333.33 | -3,333.33 |
The timeline's points are 2026-09-01, 2026-09-10, 2026-10-01, 2026-10-09 (today) and 2026-10-15, so the chart's PV steps up to 10,000 on 2026-10-15 while EV and AC stop at today.
The checklist of ten charts a project tool is expected to have, and where each one lives:
| Chart | Drawn by | File | Reads |
|---|---|---|---|
| Gantt | the Timeline tab - PrjTimeline.render() / draw(), dependency arrows in drawArrows(), Critical path toggle |
assets/js/projects-timeline.js |
get.php stages, tasks (with depends_on, waiting_on, clash, critical, slack from projectDependencyAnalysis()), dependencies, milestones - see 4 Plan, Timeline and dependencies
|
| Burndown |
PrjCharts.burndown() from renderBurnup() (view toggle) |
assets/js/projects-view.js + projects-charts.js
|
burnupPoints() from get.php tasks (§8) |
| Progress | the ring P.ring() (portfolio card and project banner); PrjCharts.burnup(); "Progress by stage" PrjCharts.stack(); portfolio Charts "Progress" PrjCharts.stack()
|
assets/js/projects.js, projects-view.js, projects-insights.js, projects-portfolio.js
|
progress / task_total / task_done from projectDecorate(); tasks per stage |
| Milestone timeline | in a project: PrjMilestones (Overview panel, Plan chips and strip) and the Timeline's diamonds; across projects: PrjCharts.milestones()
|
assets/js/projects-milestones.js, projects-timeline.js, projects-portfolio.js
|
projectMilestones() / projectMilestoneState(); portfolio_charts.php milestones
|
| Resource allocation | the Capacity page - one row per person, one cell per week, green / amber / red |
projects/capacity.php + assets/js/projects-capacity.js
|
api/projects/capacity.php → projectCapacity() (includes/projects/capacity.php) |
| Budget vs actual | "Where the money goes" PrjCharts.bars(); "Spend over time" PrjCharts.spend(); portfolio "Budget against actual" PrjCharts.bars() per currency |
assets/js/projects-budget.js, projects-portfolio.js
|
projectBudgetDetail() lines, labour and timeline; portfolio_charts.php budgets
|
| Risk matrix | the RAID tab's heat map heatMap(); the portfolio's heat map in charts()
|
assets/js/projects-tools.js, projects-portfolio.js
|
ProjectToolsService::raid(); portfolio_charts.php risks
|
| Cumulative flow | "Flow over time" PrjCharts.flow()
|
assets/js/projects-insights.js |
get.php flow - projectFlow() over project_task_flow (§9) |
| Task status breakdown | "Task status" PrjCharts.stack()
|
assets/js/projects-insights.js |
get.php tasks and flow.statuses (board order) |
| Earned value |
drawEarned() → PrjCharts.lines() with tiles |
assets/js/projects-budget.js |
projectBudgetDetail()['earned'] - projectEarnedValue() (§13) |
- Draw it with an existing
PrjChartsfunction if one fits; otherwise add one toassets/js/projects-charts.jsthat follows the same shape:fn(container, opts),frame()with a table view, a legend inserted intotools,role="img"with anaria-label, keyboard focus that shows the same tooltip as hover, and names throughtextContent. Export it onwindow.PrjCharts. - Use series slots in order and keep a series' slot the same wherever that thing appears (a status's slot is its board position). Use
viz-n/viz-n2only for "not recorded" or "not done", never as a series. A line chart needs theviz-line/viz-dotclasses for its slot inprojects.css. - Draw only when the container is visible - charts measure their width. Hook the tab's
showpath (showTab()inprojects-view.js, or ashown()on the tab's module) and check the tab panel'shidden, not the chart box's ownoffsetParent. - Every word goes through
lang/en/projects.php; the table and chart labels are passed inopts.labels. - A new reader of health or progress on the server goes through
projectTaskStats()+projectDecorate(); a new subquery against a table Database Verification adds needs a readiness guard likeprojectsPhase2Ready().