Projects Internals 5 Health and Charts - edmozley/freeitsm GitHub Wiki

Projects internals 5 - Health and charts

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. The rule: worked out on read, never stored

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


2. projectTaskStats() - the counts in one batch

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.


3. The Health settings - projectHealthConfig()

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'];

4. projectAutoHealth() - what makes amber and red

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() forces red whenever there is an exception (§6). That is not in projectAutoHealth() itself.

projectTicketSpike()

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

Worked example

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.

⚠️ The rules are written out three times: in 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.


5. projectDecorate() - the row every screen gets

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.

Where the rows come from

  • projectListRows($conn, $analystId, $f = []) - the portfolio. Company scope in SQL (activeTenantReadFilter()), members-only projects removed with projectVisibleSql() (3.3.0), optional filters q / status / mine; it selects projectPriorityColumn(), projectVisibilityColumn(), projectProposalApprovalColumn(), the active stage's name, the stage count and projectExceptionColumns(), then calls projectTaskStats() for every id once and projectDecorate() per row with one projectHealthConfig(). Order: FIELD(p.status, 'active', 'proposed', 'on_hold', 'closed', 'cancelled'), then target finish (NULLs last), then name - the page re-sorts in the browser by project_portfolio_sort.
  • projectDetail($conn, $row) - one project for its page: the decorated row, stages with task_total / task_done, top-level tasks (with estimate_hours and logged_minutes for the task and its subtasks), the last 50 history rows, and project.effort (estimate_hours, estimated_tasks, logged_hours, tasks).

6. Exceptions and tolerances - worked out, never stored

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.

The columns - projectExceptionColumns()

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

projectsPhase2Ready()

🔑 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()

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() in assets/js/projects-tools.js (exported as PrjTools.exceptionsBanner) draws the red banner, one line per breach from gates.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) when p.exceptions.length.
  • The alert scan fires project.tolerance_breached once per kind, with kind, late_days, score, over_pct, basis and allowed (see 7 Alerts, reports and AI).

Worked example

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. With project_cost_basis = forecast and a forecast of 11,500, it is a breach with over_pct = floor(15.0) = 15.

auto_health is red because of the breaches, whatever projectAutoHealth() said.


7. Asset targets - includes/projects/targets.php

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.

The rule

  • Scope - scope = 'linked' (join project_assets - the assets on the Connections tab) or 'filter' (scope_type_id and/or scope_field LIKE %scope_value%; at least one of the two is required, so a target is never "every asset"). Always COALESCE(a.tenant_id, default) = the project's company on a multi-company install.
  • Done - done_field + done_op + done_value, from projectTargetDoneFields():
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.

Counts on read

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

Health - projectTargetHealth()

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 - past due and unfinished;
  • amber - done% more than 25 points below a straight line from the target's created_datetime to due;
  • 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.

Snapshots

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, endpoint and screen

  • Writes: ProjectToolsService::saveTarget() / deleteTarget() - changeable() and assertAssets() (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 one projectTargetOptions() offers the project's company; history target_saved / target_removed.
  • Endpoint: api/projects/tools.php - GET target_options=1, target_preview=1&<rule> ({done, total}), target_assets=ID&show=left|done (at most 200, via projectTargetAssets()), all needing Assets; POST target_save, target_delete. get.php adds targets and can_assets.
  • Screen: assets/js/projects-targets.js (PrjTargets.render) draws into #pvTargets, which renderOverview() leaves empty; the dialogs are #prjTargetModal and #prjTargetListModal in projects/view.php. Phone: mobile.css LAYER 44q. Each target's line is spark(t) - a 240 × 44 inline SVG polyline of done / total per point, aria-hidden (the numbers beside it carry the meaning), with "line soon" text until there are two points. It does not use PrjCharts.
  • projectTargetsReady() probes both tables; before Verification every target read is empty.

TRAP: NOT (done) for "still to do". An asset with no status makes status = 6 NULL, and NOT NULL is NULL - the asset vanished from the "still to do" list while the count (a CASE WHEN) still counted it. projectTargetAssets() uses NOT COALESCE(($done), 0) - a TRAP: comment marks it.


8. The burn-up and burndown - projects-view.js

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 as burnup_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 (and PrjInsights.draw()).

9. Task flow history - includes/projects/flow.php (3.3.0)

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.

projectFlowSnapshot()

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

  1. api/projects/get.php - every time somebody opens the project (just before projectFlow() reads it);
  2. projectAlertsScan() in includes/projects/alerts.php - for one project after a change (afterChange()), or for every project with status proposed, active or on_hold on the hourly cron run (workflowScheduledRun()) and the portfolio's opportunistic run (at most every 15 minutes).

projectFlow() - the series, with reconstruction

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) and open (created but not finished) - with recorded: 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).


10. The chart library - assets/js/projects-charts.js (3.3.0)

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.

The shared helpers

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'

Keyboard focus and accessibility

  • Every SVG has role="img" and an aria-label the caller writes as a sentence of the numbers ("12 of 30 done...").
  • Time charts (burnup, burndown, spend, flow, lines) put tabindex="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 transparent rect over the plot area drives the same show(i) from pointermove (nearest point by x) and hides on pointerleave.
  • Bar charts (bars, stack) and milestones make each bar, segment or diamond focusable (tabindex="0"); pointerenter and focus show its tooltip, pointerleave and blur hide it. bars uses an invisible .viz-hit rect 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:focus and .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.

burnup(container, opts)

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-ref vertical line labelled with labels.target.
  • Table: date, scope, done.

burndown(container, opts)

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-n2 neutral, dashed 5 4, 1.5px - ink, not a series.
  • ideal(d) = max(0, r0 × (1 - (d - x0) / (xEnd - x0))) where xEnd is the target (or the last day without one).
  • The tooltip and the table carry both remaining and ideal.

bars(container, opts)

Grouped horizontal bars - two values per category.

opts: {rows: [{label, a, b, note?}], series: [aName, bName], fmt: v => text, labels: {table, chart, category, aria}}
  • a is slot 1, b is slot 2. A null value 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.

spend(container, opts)

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 (dashed 6 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 today line 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.

stack(container, opts)

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%") and note sit at the row's end in text colour.
  • max fixes 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.

flow(container, opts)

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-band with a 1px surface-coloured stroke between bands.
  • The legend is the series reversed, so it reads top-to-bottom like the bands.
  • marker draws 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(container, opts)

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.

lines(container, opts)

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-dot classes for slots 1-3 exist in projects.css; a fourth slot would draw with no stroke colour.
  • A null value ends that line (earned value stops today).
  • step: true draws steps (values change on a day), as spend.
  • Reference lines as spend, but without the label stacking.
  • The tooltip lists every series with a value at that point.

11. The palette - --prj-viz-* in assets/css/projects.css

: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 #fff and 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-n2 are 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). .dash on a swatch draws a dashed key; .viz-dash dashes a line; .viz-end.halo gives an end label a surface-coloured halo.

12. Who uses each chart

The project's Overview - assets/js/projects-insights.js (window.PrjInsights)

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.

  1. 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_on from the dependency analysis). A note above says the totals.
  2. Progress by stage - PrjCharts.stack() with two series, done (slot 1) and not done (slot n), and totalText giving the percentage.
  3. Flow over time - PrjCharts.flow() from d.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';

The burn-up / burndown - assets/js/projects-view.js

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.

The Budget tab - assets/js/projects-budget.js (window.PrjBudget)

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 the labour category.

    TRAP: checking the chart box's own offsetParent never 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() from b.timeline (projectBudgetTimeline()). Reference lines: the budget (b.planned), the latest baseline's budget from data.control.baselines[0].plan.budget_planned when change control is on and it differs, and the cost tolerance (planned × (1 + tol / 100)) when one is set. Money ticks are Intl.NumberFormat compact currency. The note says how many lines have no planned date.

  • Earned value - drawEarned() → PrjCharts.lines() with step: 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.

The portfolio's Charts view - assets/js/projects-portfolio.js

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:

  1. Progress - PrjCharts.stack(), done / not done per project (projects with tasks), percentage as the total.
  2. 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".
  3. Risk heat map - a 5 × 5 grid of buttons (not PrjCharts), every open risk in view counted per cell, shaded by score (< 8 low, 8-14 mid, >= 15 high); 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.
  4. 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).


13. Earned value - projectEarnedValue() in includes/projects/budget.php (3.3.0)

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. measure says 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_datetime counts as finished from its creation.
  • ev is null for points after today; now is the last point on or before today - and projectBudgetTimeline() 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_hours existed).
  • cpi is null with nothing spent, spi null with nothing planned by today, and eac / vac null when CPI is null or 0.
  • ⚠️ EAC divides by the rounded CPI (two decimals), so EAC can differ slightly from BAC × 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.

Worked example

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.


14. The ten charts (3.3.0)

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)

15. Adding a chart

  1. Draw it with an existing PrjCharts function if one fits; otherwise add one to assets/js/projects-charts.js that follows the same shape: fn(container, opts), frame() with a table view, a legend inserted into tools, role="img" with an aria-label, keyboard focus that shows the same tooltip as hover, and names through textContent. Export it on window.PrjCharts.
  2. 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-n2 only for "not recorded" or "not done", never as a series. A line chart needs the viz-line / viz-dot classes for its slot in projects.css.
  3. Draw only when the container is visible - charts measure their width. Hook the tab's show path (showTab() in projects-view.js, or a shown() on the tab's module) and check the tab panel's hidden, not the chart box's own offsetParent.
  4. Every word goes through lang/en/projects.php; the table and chart labels are passed in opts.labels.
  5. 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 like projectsPhase2Ready().
⚠️ **GitHub.com Fallback** ⚠️