Jira Metrics

How this dashboard works

A quick reference for every number on the dashboard — what it measures, the formula, and where to see it. Skim the headings; each section is two lines + a one-line formula.

Two views, two audiences: /flow is the AI-first executive landing — bottom-line narrative, headline KPIs, AI in teams. /overview is the engineering detail view — every per-metric table, AI Period comparison, longest-stuck list.

Anatomy of a ticket

View per-project data →

Hover any metric for its formula.

Jira Flow Lead Time (raw / wall-clock)
Lead Time (active, ÷ pauses)
Time to Ready
Cycle Time (raw / wall-clock)
Lead Time for Changes
Cycle Time (active, ÷ pauses)
Time to First PR
PR Review Duration
Created
Ready
In Progress
In Review
Done
Deployed
Pauses ÷ThroughputWIPBug RateTime in StatusAI %Deployment FrequencyChange Failure RatePR Acceptance

Quick guide — which number do I quote?

📊 Talking to leadership

Quote Wall-clock (raw) — Jira Flow Lead Time (created → done) or Cycle Time (start → done) in pure calendar days. Includes everything; what stakeholders feel. Note: this is a Jira ticket-flow metric, not DORA Lead Time for Changes (which is code-committed → production-deployed).

🛠️ Talking to engineers / retro

Quote the activeversion — "How long tickets actually take" or "How fast we actually work". Business hours only, pauses subtracted. Fair to the team because blockers outside their control don't count.

🎯 Where is work stuck?

Look at Where Work Waitson Overview — the stage with the highest median time is the bottleneck. For drill-down: open the project's Flow page (Time in Status).

Are we faster with AI?

Three places give layered answers:/flowhas the 1-sentence AI Bottom Line + AI ROI banner with Δ% vs prior period./overview AI Impactshows AI vs Human medians on 5 KPIs.Before vs After AIasks the longitudinal question: is the team faster now than before AI was introduced?

Medians, n, and cohorts — how to read every number

Every "AI vs Human" comparison on this dashboard follows the same rules. Read this once and the rest is just numbers.

What is a median (p50)?

Sort every ticket's lead time from smallest to largest, pick the middle one. That's the median. Half the tickets were faster, half were slower.

Example: tickets took 1h, 2h, 4h, 10h, 50h, 500h, 800h (7 tickets) → median = the 4th value = 10h. The 800h outlier doesn't move it. Average of the same list = 195h — misleading.

Why we use it everywhere.One ticket that sat 6 months in Backlog can blow up an average. Median ignores tails and gives you "what a typical ticket looks like."

What is "n"?

Sample size — how many tickets / PRs fed into the median for that row.

Critical:n does NOT change the median's value. It only tells you how reliable that median is.

  • n ≥ 10:reliable — the median is stable, one outlier ticket can't swing it much.
  • n < 10: directional — value is still shown but flagged. One unusual ticket can shift the median dramatically.

AI cohort vs Human cohort vs All

  • AI cohort— the ticket's assignee at completion time is an active agent (their account is in agent_accounts with active = true). For PR-level metrics, the PR's ADO author is an active agent. Manage the active list at /settings/agents.
  • Human cohort — everyone who is NOT an active agent, including unassigned tickets. We include unassigned because excluding them would quietly shrink the baseline and bias the comparison.
  • All — every measurable ticket / PR in the window. Always equals AI + Human in count. Useful as a project-wide reference.

Tickets and PRs are bucketed strictly into one cohort. A ticket reassigned from human to AI mid-flight lands in the cohort matching the final assignee at completion.

Worked example — AI 126h (n=24) vs Human 15.8d (n=85)

A common question: "Does Human take longer because they did more tickets (85 vs 24)?"

No.n is just a count. The median is the middle value among those n; the count of tickets doesn't make the value larger or smaller.

  • AI: 24 tickets sorted by lead time → the 12th–13th values average to 126h.
  • Human: 85 tickets sorted by lead time → the 43rd value is 15.8 days.

Why Human is actually higher here comes from the composition of the work, not the count:

  • Selection bias. AI typically picks up smaller, well-scoped tickets (refactors, audits, config fixes) — those naturally complete faster.
  • Backlog drag.Human cohort often includes older tickets that sat in Backlog before being picked up — that "shelf time" gets added to lead.
  • Zombie cleanup. If the zombie filter is in flag mode, very old tickets that get swept inflate the Human median. Switch to exclude on the project config to drop them.
  • Ticket size mix. Human-driven work spans the whole portfolio including large epics; AI work is typically narrower in scope.

To investigateclick "🧠 Generate AI deep analysis" on /anatomy— the LLM will name the specific tickets dragging each side's median.

When to trust the Δ%, when to ignore

ConditionToneWhat it means
Both n ≥ 10, |Δ| ≤ 5%parityWithin noise. AI and Human perform the same here.
Both n ≥ 10, |Δ| ≤ 200%readable signalReal difference. Quote it.
|Δ| > 200%inspectAlmost always 1-2 outlier tickets dragging a median. Open AI deep analysis.
Either n < 10directionalA hint, not a conclusion. Widen the range or wait for more samples.

How our app measures these statuses & timings

The diagram above shows the canonical lifecycle. Our metrics never hard-code the status names— every JIRA project has its own workflow (e.g. ADS uses "Approved for Sprint", TDOT uses "New", etc), so each metric reads a list of statuses from metric_configs at compute time. Defaults below cover the most common JIRA naming; override them on Project Configuration if your team uses different labels.

How these defaults were chosen. We ran an audit script (scripts/verify-metric-defaults.mjs) over the synced data of all 35 projects, enumerated every literal status name that actually appears in issue_transitions, grouped them by JIRA's canonical statusCategory(Done / In Progress / To Do), and picked the broadest sensible set for each metric — excluding cancellations ("Removed", "Deprecated"). Result: ~130 additional in-flight tickets newly visible to WIP across the five biggest projects, with cycle/lead time samples up ~10% per project. Existing projects whose configs still matched the OLD defaults were auto-migrated on the first request after the deploy; any project that had been manually customised is left untouched.

MetricConfig fieldsDefault values
Cycle TimestartStatuses, endStatuses, pauseStatusesstart (status transition): In Progress · In Development · In Testing · Discovery · Delivery
end (status transition): Done · Closed · Released On Live · Released On Stage · Resolved
pause (time subtracted): On Hold · Waiting for Client · Waiting for approval
Formula: endTransition − firstStartTransition − pauseTime
Lead TimeendStatuses, pauseStatuses (no startStatuses — start is always the ticket's createdAt)start: issue.createdAt (JIRA creation timestamp — implicit, not a status)
end (status transition): Done · Closed · Released On Live · Released On Stage · Resolved
pause (time subtracted): same as Cycle Time
Formula: endTransition − createdAt − pauseTime. That's the "Created → Done" bracket on the timeline above — wider than Cycle Time because it includes everything before pickup (triage, grooming, waiting).
Throughputlead_time.endStatuses (inherits Lead Time completion statuses)Done · Closed · Released On Live · Released On Stage · Resolved (Removed/Deprecated = cancelled, excluded). Throughput has no separate status list; metric_configs.throughput is intentionally empty.
Time to ReadyreadyStatusesSelected for Development · Approved for Sprint · Approved · Ready · Triaged · In Progress · In Development
Time to First PRstartStatuses, includeDraftsIn Progress · In Development · drafts counted
WIPactiveStatuses, pauseStatusesActive WIP uses activeStatuses - pauseStatuses. Blocked WIP uses pauseStatuses, so overlapping pause statuses are not dropped; they move from active to blocked.
Time in Statusstages (ordered)Canonical lifecycle order mapped to every observed literal status name in the default config (37 statuses). Aggregations expose p50, p75 and p90.
Bug RatebugIssueTypesBug · Incident
Planned vs UnplannedunplannedIssueTypes, unplannedLabels, endStatusestypes: Bug · Incident · labels: [] · end: Done · Closed · Released On Live · Released On Stage · Resolved. Stored as planned_vs_unplanned.endStatuses; defaults match completion statuses but are separately configurable, not inherited from Throughput.

How transitions are sourced.Every JIRA status change is read from the ticket's changelog (returned inline by POST /search/jql?expand=changelog) and stored in issue_transitions(issueKey, fromStatus, toStatus, changedAt, authorAccountId). Each metric then queries: find the FIRST transition whose toStatus ∈ startStatuses for start times, and the LAST transition whose toStatus ∈ endStatuses in rangefor completions — which is more reliable than JIRA's resolved_at field that workflows occasionally forget to set.

How pauses are subtracted. For Cycle Time and Lead Time, every interval the ticket spent in a pauseStatuses status (default: On Hold · Waiting for Client · Waiting for approval) is computed via computePauseMs(transitions, pauseStatuses, periodStart, periodEnd, calendar) and subtracted from the raw duration. The interval for a pause starts at its changedAtand ends at the next transition (or periodEnd if none). Pause time is measured in business hours via the project's working_calendar, so a ticket parked on Friday and unparked on Monday doesn't artificially eat the weekend. Visible per-row on metric pages as the +Xd paused suffix next to the cycle/lead value. The active set in our data has ~5,200 pause transitions across all projects — this is a meaningful adjustment, not a cosmetic one.

Lead Time & Cycle Time — two variants side by side

Industry standards disagree on whether to subtract pauses. Reinertsen and some Kanban practitioners treat Lead Time as wall-clock (customer-facing) while others subtract pauses (team execution view). Note: DORA Lead Time for Changes is a different metric — it measures code-committed → production-deployed (see the per-project Lead Time for Changes page), not Jira-ticket-flow. To avoid confusion, we call ours Jira Flow Lead Time. To avoid hiding either signal, we compute BOTH variants (raw + active) and expose them on the Lead Time page (and visualise both on the "Anatomy of a ticket" diagram above — solid bracket = raw; dashed = active).

VariantFormulaIndustry sourceUse when
Lead Time — raw (wall-clock)completionAt − createdAtReinertsen, Kanban Lead Time (Jira-flow proxy; not DORA Lead Time for Changes)Stakeholder / customer view — total wait. Comparing to industry benchmarks. Surfaces systemic waits.
Lead Time — active (÷ pauses, business hours)businessHours(createdAt → completionAt) − pauseTimeKanban variant (active flow)Team-execution view — excludes blockers outside team's control (PM waiting, vendor, weekend).
Cycle Time — raw (wall-clock)completionAt − firstStartTransitionSome Kanban writeupsWhen you want apples-to-apples comparison with industry cycle-time benchmarks.
Cycle Time — active (÷ pauses, business hours)businessHours(firstStart → completionAt) − pauseTimeKanban (active flow); our defaultEngineering speed once work has started — most useful internal signal.

Recommendation: use Jira Flow Lead Time (raw) in conversations with leadership and when comparing to Kanban/Reinertsen benchmarks. Don't compare it directly to DORA Lead Time for Changes — those are different scopes (Jira ticket vs code-to-prod). Use Cycle Time (active)for team retrospectives and engineering-pace tracking. The Lead Time page shows both p50 figures side-by-side so you can quote the right one.

How we count deployments (DORA)

Deployment Frequency, Change Failure Rate and Lead Time for Changes all read from a single table — deployments— populated from Azure DevOps. We don't infer deploys from JIRA statuses or git tags.

ADO scope — single-org limitation. ADO repo and pipeline mappings are scoped to one ADO organisation per install (the one pointed at by the ADO_ORG_URL environment variable). Arkadium setups that span more than one Azure DevOps organisation need a separate instance of this dashboard per org — the discovery walk and the PR / deployment sync never cross orgs.

  • What gets registered as a pipeline. When you run Sync, our auto-discovery walks every ADO release definition (and, optionally, deploy-named YAML build pipelines) in the ADO project where the mapped JIRA repo lives. Each becomes an ado_pipeline_configsrow tied to your JIRA project key.
  • What gets stored as a deployment. For each registered pipeline, we pull recent runs/releases via the ADO REST API. We record the rolled-up result across its environments: inProgress / succeeded / failed / partiallySucceeded / canceled. Deployment Frequency and Change Failure Rate both use the same classifier. Only succeeded counts toward Deployment Frequency; failed,partiallySucceeded and canceled enter the CFR denominator and count as failures unless marked manual not-failure; inProgress is excluded.deployments.manualFailure=true forces failure and also removes the row from Deployment Frequency.
  • How a PR maps to a deploy. For Lead Time for Changes, each merged PR is associated with the next deploy after its merge time on a pipeline registered for the same JIRA project. We never link PRs across projects.
  • What "production" means. We currently treat ANY successful deploy as a deploy event. If your release definition has a separate Prod env, configure the pipeline filter on Project Configuration to keep only that env. (Default = all envs.)

Verification — what to check if a number looks off

  1. Did sync run successfully? Open /logs; any project with a recent error row will have stale data. The /sync tile shows a red ⚠ failing chip on misbehaving projects.
  2. Are the statuses configured for your workflow?If a project uses "Code Review" instead of "In Review", our default WIP / Time in Statuslists won't catch it. Open Project Configuration and add the missing name.
  3. Is the ADO pipeline registered?If Deployment Frequency is zero for a project that clearly deploys, the release definition wasn't auto-discovered (maybe mapped repo lives in another ADO project). Add it manually on Project Configuration.
  4. Does an outlier ticket need exclusion? Use /exclusions. Issue exclusions are centralized rows with scope = "all": the ticket is removed from issue metrics and linked-PR lanes. PR-only exclusions are for PRs without a linked JIRA key or for a single PR outlier. Metrics recompute on the next request.

What this dashboard is for

One question: how is the team doing, and where is AI useful? The Overview page (the home page) is the single executive view; per-project pages let you drill into one team in detail.

Source of truth is JIRA + Azure DevOps. The app pulls data into a local SQLite snapshot when you press Sync; nothing is recomputed live against JIRA. That means numbers are as fresh as the last sync (see top of Sync page).

What "AI" means in the numbers

An "agent" is a JIRA account marked active in Settings → Agents. Today there are 7: Oliver, Amelia, Anastasia, Sakura, Nikolaos, Veronica, Robocop.

  • For JIRA-issue metrics (cycle / lead / throughput / bug rate / etc.) — assignee ∈ agents = AI work.
  • For ADO PR metrics — PR author ∈ agents = AI PR.
  • Reporter (the creator) is tracked separately in AI Activity only — not in the throughput-style metrics, because creating a task is not the same as doing it.

Leadership requirements — and where each lives

#1

See teams holistically

"We need to bring the app to life so we can start seeing teams holistically."

Where: /overview

How: Single story-scroll page: Right Now KPIs → 8-week Trend → AI Impact deltas → Top Teams board → AI Activity per agent. No drill-down required.

#2

Know team performance in numbers (baseline)

"Unfortunately we don't really know our team's performance in numbers."

Where: Overview + 12 per-project metric pages

How: Jira-flow (cycle, lead, throughput) + DORA code-path (deploys, change failure, lead-time-for-changes), plus quality (bug rate), planning (planned vs unplanned), and PR flow (PR cycle, review, acceptance, time-to-first-PR).

#3

See where AI is most useful

"See how agents are actually being used and what impact they have on the team."

Where: Overview "AI Impact" and "AI Activity" sections

How: Every metric splits agent vs human. Impact section shows the delta (Δ%). Activity panel counts what agents actually do (assigned, authored PRs, reported, status moves, comments).

#4

Lead time as a key non-code signal

"Lead time can be a good metric to tap into what you are saying."

Where: Lead Time per project

How: Days from issue creation to resolution. Applies to any JIRA work — code, planning, analysis, infra — not only engineering tickets.

#5

How fast a task is ready to be worked

"For example, how fast a task is ready to be worked."

Where: Time to Ready per project

How: Hours from issue creation to the first move into a "ready" status (Selected for Dev / Ready / Approved / Approved for Sprint / In Progress). Configurable per project.

#6

Metrics not only about code

"How well and how fast a team performs is not only related to coding."

Where: Lead time, cycle time, time-to-ready, status breakdown, planned vs unplanned

How: These all operate on any JIRA tickets (planning items, analyses, ops tasks). The PR-specific metrics (PR cycle, PR review, PR acceptance, deployment frequency) are the code-only subset.

#7

Track AI as planner, analyst, task writer — not just developer

"The agent isn't only for developers now, it's also being used for planning, issue analysis and writing tasks."

Where: Overview → AI Activity panel

How: Reported = issues created by an agent (task writing). Transitions = status moves performed by an agent (planning / triage). Comments = comments left by an agent (analysis / notes). All three are off-code signals.


Overview KPIs — formulas

Issues done (Right Now)

How many tickets got finished in the chosen range. Uses the canonical completedIssueSet — same cohort that drives Top Teams and AI Impact, so the rollup reconciles end to end.

count(issues) where LAST transition into a configured end status falls in range AND current status is still in end statuses AND project in scope
Resolved tickets per weekper ISO week ◂ 8 weeksLast 30d total ≈ 336

Example: Last 30 days, leadership scope → 336

Tells you: Pure volume. A larger number is more output — but says nothing about quality on its own.

AI participation %

What share of the canonical completedIssueSet is owned by an agent (as assignee at completion time).

issuesDone(assignee ∈ active agents) / issuesDone × 100
by agentsall completed13%42 / 336 = 12.5%

Example: 42 of 336 leadership-scope issues → 12.9%

Tells you: How much of the team's throughput AI is producing — over time, this trend is the headline.

Cycle Time (active)

Typical active hours between starting work on a ticket and finishing it. As of audit23 T1, Overview and the per-project Cycle tile both use the active cycle math: business-hours between firstStart and completion, with every interval the ticket spent in a configured pauseStatuses subtracted. Active time = business hours minus pauseStatuses time. Median (p50) is robust to outliers. Start statuses are the union of cycle_time.startStatuses across the projects in scope (defaults: In Progress · In Development · In Testing · Discovery · Delivery). Matches the "Cycle Time (active, ÷ pauses)" band in Anatomy of a ticket.

median(businessHours(firstStart → completionAt) − pauseTime) in hours — the active variant
CreatedReadyIn ProgressIn ReviewDonecycle time (active)p50 ≈ 2.9d

Example: Cycle Time (active) = 2.9d means half of tickets finished faster than 2.9 active business days from first In-Progress-ish move (pauses already subtracted).

Tells you: How long it takes the team to push something through once they pick it up, ignoring time blocked outside their control. Smaller = faster execution.

Deployments / week

Average successful deployments per calendar week. Counts releases across ADO release definitions mapped to the project(s) — only result='succeeded', anchored on finishedAt.

count(successful deployments) in range / weeksInRange
Successful deploys per weekper ISO week ◂ 8 weeksDORA #1

Example: 6.3 / week, last 30 days, leadership scope

Tells you: One of the DORA "four keys" — the higher, the more delivery cadence. Watch in conjunction with change failure rate.


AI Impact rows — formulas

Each row is "agent median vs human median, with Δ%". Δ% is(agent − human) ÷ human × 100, colored green when better for the team (typically faster). All inside the chosen range and projects.

Cycle Time (active) (agent vs human)

Are agent-owned tickets going through the same active in-progress → done time as human-owned ones? Uses the same business-hours-minus-pauses math as the per-project Cycle Time active tile.

median(businessHours(firstStart → completionAt) − pauseTime) over agent-assigned vs human-assigned completed tickets
AI · cycle p50 (h)82hHuman · cycle p50 (h)68hleadership · last 30d · n=41/274

Tells you: If agents are noticeably faster, AI is shortening the cycle; if slower, they're working harder tickets or hitting friction.

Lead Time (active) (agent vs human)

Active ticket age — creation to canonical completion in project business hours, minus configured lead_time.pauseStatuses — split by who eventually owned it. Derived from the same completedIssueSet as Issues done so AI and Human n's match the headline cohort.

median(businessHours(createdAt → completionAt) − pauseTime) where completionAt = last in-range end transition, split by assignee, in hours
AI · lead p50 (h)102hHuman · lead p50 (h)139hleadership · last 30d · n=42/294

Tells you: Broader signal than cycle; includes time before pickup. Useful for non-code teams.

Time to Ready p50 (agent vs human)

Leadership-named example metric. How fast a ticket becomes "workable" — measured against the project's ready-status list.

median(firstReadyTransition − createdAt) over agent vs human assignee, in hours
CreatedReadyIn ProgressIn ReviewDonetime to readyAI 0.04h vs Human 14.4h

Tells you: If agents process the planning/grooming pipeline faster, you'll see it here.

PRs merged (agent vs human)

Raw PR throughput by author.

count(pullRequests) where status = completed AND author ∈ agents (or humans), in range
AI · PRs merged30Human · PRs merged101leadership · last 30d

Tells you: How many merged PRs AI authors are producing alongside humans.

PR review p50 (agent vs human)

Time from first reviewer touch to merge — this is PR Review Duration, the actual time spent in review. Sibling per-project metric Time to First Review measures businessHours(createdAt → firstReviewAt) — how long until the first reviewer touched the PR through the project working calendar. The two answer different questions: how long until review starts vs how long review takes. Both have dedicated per-project pages.

median(completedAt − firstReviewAt) for PRs author ∈ agents vs humans, in hours
AI · review duration (h)29hHuman · review duration (h)21hleadership · last 30d · n=30/101

Tells you: If agent PRs are reviewed faster, they're either smaller or clearer — both good.


AI Activity columns — formulas

Assigned

Completed tickets where the agent was the assignee at completion. Uses the canonical completedIssueSet so the count matches Overview's AI participation × Issues done exactly.

count(completedIssueSet) where assignee ∈ agent in range
Agent-assigned completions · current snapshot42leadership · last 30d

Authored PRs

PRs the agent opened.

count(pullRequests) where author = agent.adoUserId AND createdAt in range

Reported

Tickets the agent created (task writing). Reporter is JIRA's "created by".

count(issues) where reporter ∈ agent AND createdAt in range

Transitions

Status moves the agent performed (any → any). A proxy for "agent doing workflow ops" — moving cards, triaging, approving.

count(issue_transitions) where author = agent AND changedAt in range

Comments

JIRA comments left by the agent. Useful proxy for "analysis" or "writeups" that don't produce code.

count(issue_comments) where author = agent AND createdAt in range

Tells you: 0 until a sync runs with the comments-fetch enabled. After one sync, this populates incrementally.


Before vs After AI — comparing eras

A second lens on AI impact. Instead of agent-vs-human at the same moment, this splits each project's timeline at the date AI agents started doing meaningful work, then compares the same team's metrics before vs after.

timeProjectfirst ticketAI introduction dateauto-detected or manualNowBefore AI (baseline)After AI (with agents)DISPLAY MODESCompare+ Δ%Before onlyAfter onlyOffsection hiddenAUTO-DETECT USES MIN OF:• first agent-assigned ticket (createdAt)• first agent-reported ticket• first agent comment• first agent status transition

Why this matters.AI-vs-human at the same instant can be confounded by selection bias (agents picking easier tickets). Before-vs-after with a fixed split point shows whether the whole team's baseline moved.

Per-project configuration.Each project has its own AI introduction date because teams adopted agents at different times. Set on the project's config page (AI Period block):

  • Auto-detect picks the earliest of: first agent-assigned ticket, first agent-reported ticket, first agent comment, first agent transition.
  • Manual — set any date with the picker. The marker on the panel shows which source was used (auto / manual).
  • Toggle Mode: Off, Before only, After only, or Compare (side-by-side with Δ%).

What you see on Overview. A rollup table with one row per metric. Each row exposes a per-metric ⓘ tooltip with the exact formula; the headers (Metric / Before AI / After AI / Δ) and the per-project card pieces (split date, mode badge, weeks·n, each KPI in the side-box) all have their own ⓘ — hover for the explanation.

  • Lead time p50 (raw, days) — median-of-per-project-medians. Per-project formula: completionAt − createdAt in calendar days, then p50. completionAt is the last in-window transition into a configured end status — the same value used by the canonical completedIssueSet.
  • Throughput / week — mean of count(resolved) ÷ weeksacross projects.
  • AI participation % — per-project aiAssignedDone ÷ totalDone × 100, then averaged across projects.
    Note — AI Period aiPct is assignee-only: a ticket counts as AI iff its current assignee is an agent. The broader "AI touched" predicate (reporter OR comment author OR transition author OR assignee) is what the AI Impact and Agent Activity tiles use, and it is not the same metric — Before-vs-After AI deliberately picks the narrower lens so the split is unambiguous about ownership.
  • Deploys / week — mean of per-project count(successful ADO deploys) ÷ weeks. Uses the shared DORA classifier (result = "succeeded", excluding manual failures), anchored on finishedAt. DORA #1.
  • Change failure rate — weighted by deployment count. classified failures ÷ classified finished deploys × 100 per project, Σ(rate · weight) ÷ Σ weight across projects. DORA #4.

Δ% — change from Before to After. (after − before) ÷ before × 100. Color rule: ▲ / ▼ green = improvement (direction depends on the metric: more throughput is good, lower lead time is good), ▲ / ▼ amber = regression. = sample too small on at least one side (n < 30). = no Δ available (Before or After was empty, or Before = 0).

Expand Per-project breakdown to see one card per project with both eras side-by-side, sample size (n), the split date, and the active mode badge.

Before window anchoring.The Before period does NOT start at the Unix epoch — it's anchored at the project's first recorded ticket (MIN(issues.createdAt)) so per-week throughput stays comparable regardless of when the project itself was created.

How aggregation works.

  • Percentile metrics use median-of-per-project-medians (resistant to outliers).
  • Throughput / deploys use the mean of per-project rates.
  • Change failure rate uses a weighted average by deployment count, so a project with 100 deploys counts more than one with 10.
  • Sample size (n) is the total resolved tickets across projects in that era.

⚠ Caveats — read before quoting.

  • Correlation ≠ causation. Team size, scope mix, hiring, and process changes co-vary with AI adoption.
  • Sample size matters. Two thresholds:
    • n < 30 per side — value is dimmed and Δ% is replaced with ⚠ to prevent over-reading a percentage change. Drill into Per-project breakdown to see which project is thin.
    • n < 10 in Before for a Compare project — see auto-fallback below.
  • Compare → After-only auto-fallback.If a project has < 10 pre-AI tickets while configured as “Compare”, we silently switch it to “After only” and label the card with the reason (e.g. "not enough pre-AI data (n=3) — showing After only"). Cleaner than printing a spurious Δ%.
  • Work-mix shift. If the team did mostly bugs before AI and mostly features after (or vice-versa), the comparison is unfair. Mention this when quoting.

When to switch modes.

  • Off — project never used AI, or the comparison adds no value.
  • Before only— historical baseline view for projects we're about to introduce AI into.
  • After only— new project / new team without pre-AI history. Or when the pre-AI history is contaminated and you don't want it weighing the rollup.
  • Compare — the headline view for any team that has 1+ month of data on each side.

All per-project metrics — quick reference

Cycle Time

view on ADS →
businessHours(firstTransitionTo(cycle_time.startStatuses) → completionAt) − pauseTime
CreatedReadyIn ProgressIn ReviewDonecycle timefirst In-Progress → completion

Unit: days

Default displayed value is the active variant: business hours minus cycle_time.pauseStatuses. The page also shows the raw wall-clock variant side by side. Start statuses are configurable per project.

businessHours(createdAt → completionAt) − pauseTime
CreatedReadyIn ProgressIn ReviewDonelead timecreated → completion

Unit: days

Default displayed value is the active variant: business hours minus lead_time.pauseStatuses. The page also shows raw wall-clock Lead Time (completionAt − createdAt). completionAt comes from the canonical completedIssueSet.

Time to Ready

view on ADS →
firstTransitionTo(ready.readyStatuses) − createdAt
CreatedReadyIn ProgressIn ReviewDonetime to readycreated → first Ready

Unit: hours

Ready statuses configurable. Defaults: Selected for Development, Approved for Sprint, Approved, Ready, Triaged, In Progress, In Development. NOTE: the per-project Time-to-Ready page measures the EVENT cohort (first-ready in range); Overview measures the same metric on the completed-in-range cohort. Both formulas are valid for different questions.

Throughput

view on ADS →
count(completedIssueSet) per ISO week
Throughput · last 8 weeksper ISO week ◂ 8 weeks

Unit: tickets/week

Bar chart of weekly completions; AI vs human stacked. Uses the canonical completedIssueSet — same as Overview's Issues done.

count(issues where issueType ∈ bugIssueTypes) created per week
Bugs created · last 8 weeksper ISO week ◂ 8 weeks

Unit: bugs/week

Issue type list configurable; defaults to Bug, Incident.

Planned vs Unplanned

view on ADS →
unplanned / total × 100, where unplanned = issueType ∈ unplannedTypes OR label ∈ unplannedLabels
Planned vs Unplanned78%planned22%unplanned

Unit: % unplanned

Tracks interrupts vs deliberate work. Defaults: unplanned types = [Bug, Incident]. NOTE: this is NOT 'no sprint at create time' — sprint info isn't tracked in our schema.

Status Breakdown

view on ADS →
business time spent in each workflow status for issues with transitions in the selected range
Hours spent per stage20%Backlog35%In Progress25%In Review15%QADone

Unit: hours per status

Shows TIME spent in each workflow stage, not a count snapshot. This is the per-status flow distribution — i.e. which stage absorbed the most time on average.

Pull Requests

view on ADS →
count(PRs merged) per week, by author segment
PRs merged · last 8 weeksper ISO week ◂ 8 weeks

Unit: PRs/week

Lists every merged PR; group/sort/export available.

PR Cycle Time

view on ADS →
businessHours(createdAt → completedAt)
CreatedReadyIn ProgressIn ReviewDonePR cycle (created → merged)PR open → merge

Unit: hours

Total PR lifecycle from open to merge, measured through the project's working calendar. PRs are filtered by completedAt in range.

Time to First Review

view on ADS →
businessHours(createdAt → firstReviewAt)
CreatedReadyIn ProgressIn ReviewDonetime to first reviewPR open → first reviewer touch

Unit: hours

How long until the first reviewer touched the PR — the first-touch metric, measured through the project's working calendar and filtered by PR createdAt in range. Sibling to PR Review Duration (below): together they decompose the full review window into 'wait to start' and 'review itself'.

PR Review Duration

view on ADS →
completedAt − firstReviewAt
CreatedReadyIn ProgressIn ReviewDonereview durationfirst review touch → merge

Unit: hours

How long the review takes once it has started. Sibling metric to Time to First Review. The Overview AI Impact 'PR review p50' row uses THIS formula.

PR Acceptance Rate

view on ADS →
completed / (completed + abandoned) × 100
mergedmerged + abandoned86%higher is better

Unit: %

Quality signal: how much of what's submitted gets in.

Time to First PR

view on ADS →
businessHours(first transition into time_to_first_pr.startStatuses → first linked PR createdAt)
CreatedReadyIn ProgressIn ReviewDoneticket-in-progress → first PRstarts at In Progress

Unit: hours

How long from starting a ticket until code is up for review. The start status list and draft-PR inclusion are configurable; defaults are In Progress / In Development and include drafts. Negative deltas are clamped to 0.

Deployment Frequency

view on ADS →
count(deployments) per week
Successful deploys per weekper ISO week ◂ 8 weeks

Unit: deploys/week

DORA key #1. Only succeeded deployments (result='succeeded'), anchored on finishedAt.

Change Failure Rate

view on ADS →
failed / total deploys × 100
failedtotal deploys23%lower is better

Unit: %

DORA key #4. failed, partiallySucceeded and canceled count toward 'failed'; manualFailure=true forces failure, and manualFailure=false marks a non-success result as not-failure without making it a successful deploy. inProgress is excluded. Lower is better.

Lead Time for Changes

view on ADS →
businessMsBetween(pr.completedAt, firstSuccessfulDeploy.finishedAt) in hours
CreatedReadyIn ProgressIn ReviewDonePR merged → first deployDORA #2 · business hours

Unit: hours

DORA key #2. Code-to-prod latency: business hours from PR merge to the first successful deployment for the same ADO project after the merge. Weekends and off-hours are excluded so blockers outside the team's working calendar don't inflate the number.


Flow metrics (newer)

WIP — current Work in Progress

How many tickets are open in an active state right now. Pause statuses are counted separately as Blocked WIP, not dropped. The Overview's WIP KPI is the same active number.

count(open issues where status ∈ activeStatuses - pauseStatuses)
Tickets in active status · current snapshot270… +222 moreleadership scope · now

Example: WIP = 270 means 270 tickets are in In Progress / In Review / QA / Testing / Ready For Live / etc right now.

Tells you: High WIP often means the team is overloaded or losing focus. Compare to throughput — if WIP grows while throughput stalls, the team is pulling more in than it ships.

Time in Status / Flow

Shows where work actually waits — by stage of the workflow. The Flow page also flags the slowest stage as the bottleneck.

for each ticket, sum hours between consecutive status transitions; aggregate per stage (median, p75, p90)
Hours spent per stage15%Backlog30%In Progress30%In Review15%QA10%Doneamber stage = bottleneck candidate

Example: If In Review median = 18h and Coding median = 4h, the bottleneck is reviewer availability — not engineering speed.

Tells you: Long 'In Review' median = reviewers slow. Long 'QA' median = handoffs blocked. Long 'In Progress' = scope creep or too much WIP.

Where Work Waits — Active Flow Bottlenecks (Overview)

Bar list of active workflow stages ordered by median wait, with the slowest banner-highlighted as the team-wide active bottleneck. Audit16 P0 split: this card answers 'where does started work get stuck' — separate from where work waited BEFORE being picked up.

across projects in scope: median over per-project medianHours per stage; bulk-computed via work-waits-bulk.ts in one SQL pass. End statuses (Done · Closed · Released On Live · Released On Stage · Resolved) are EXCLUDED entirely. Intake/backlog statuses (New · Open · Backlog · To Do · Approved · Selected for Development · Approved for Sprint · Ready · Triaged) are routed to the sibling 'Backlog / Intake Aging' card.
Active stage ranking by p50 wait30%Waiting for Client22%Failed On Testing20%Ready For Live16%In Review12%In Progress

Tells you: The single most useful number for asking 'where do we lose time once work has started'.

Backlog / Intake Aging (Overview, sibling card)

Bar list of intake/backlog stages ordered by median time waiting BEFORE execution started. Useful for refinement/triage health — not the same question as flow bottlenecks.

same compute as Active Flow Bottlenecks, but filtered to intake-only statuses: New · Open · Backlog · To Do · Approved · Selected for Development · Approved for Sprint · Ready · Triaged
Intake stage ranking by p50 wait38%New26%Backlog20%To Do10%Approved for SprintSelected for Development

Tells you: If this card's medians are huge while Active Flow Bottlenecks is healthy, the team isn't slow — they have too much pre-execution debt.

Longest Stuck Right Now

Triage list: the 8 tickets in the team that have been stuck in active states the longest.

active tickets sorted by max(now − firstActiveTransition, now − createdAt) descending
Stuck tickets surfaced · current snapshot8top 8 by age, leadership scope

Tells you: Direct action list. Click into them, find out why, decide: finish, drop, or unblock.


Data flow & freshness

  1. JIRA + ADO are the external sources of truth for issues, transitions, comments, PRs and deployments. Agent accounts, project metadata, repo/pipeline mappings, calendars, metric configs and exclusions are local app settings in SQLite.
  2. Pressing Sync kicks off a worker pool that fetches external data via REST and writes to local SQLite. The page itself never talks to JIRA/ADO.
  3. SQLite lives on a Kubernetes PersistentVolumeClaim — survives redeploys and pod restarts.
  4. Every metric page is a pure read from SQLite. Metric range/assignee/aggregation filters are applied by the server while computing the metric; DataTable search, column filters, sorting, grouping, pagination and export are client-side over the already-loaded rows.

JIRA sync — what we actually do

ADO sync — what we actually do

Where to see what happened


Filters & configuration

Top-bar (Overview)

Per-project (metric pages)

Per-project configuration


What this does NOT do (yet)


Data Quality panel

Bottom of Overview. Seven honest checks against the data the rest of the page is computed from, rendered as a 4-column table (Check · Status · Current value · What it means). Hover the ⓘ on each column header for what that column shows; hover the ⓘ next to each check name for Formula · Example · When to worry · What to do. Amber = a caveat to mention when quoting the rollup; not necessarily a bug.

Migration when defaults change. When DEFAULT_CONFIGS in src/lib/config.tsgains new statuses, stored project configs don't auto-update — they only get upgraded on the next page visit via the LEGACY_DEFAULTS exact-match in ensureDefaultConfigs(). To proactively migrate all 35 projects at once, run npm run db:migrate-configs (calls ensureDefaultConfigsfor every project; prints before/after counts of wait-status presence). The cancellation whitelist excludes Removed/Deprecated/Cancelled/Won't Do/Rejected/Duplicate statuses.

Change Failure Rate weighting. The Before vs After AI rollup's CFR row is a weighted average across projects. The weight is totalDeploymentCount from the shared DORA classifier (successful deployments plus finished non-success deployments, with manual failure/not-failure overrides applied), NOT just successful deploys — otherwise a project with high failure rates gets under-weighted and the rollup looks optimistic. Throughput / Deploys-per-week n-counters still use successfulDeploymentCount (those measure delivery cadence, not exposure to failure).


AI Narrative Layer

The dashboard has a content-aware AI layer that writes the executive narrative on top of the deterministic metrics. The AI never calculates metrics; it explains pre-computed numbers from the evidence pack and cites evidence IDs for every claim. When AI is off or fails, the deterministic templated narrative fills in seamlessly.

How a request becomes a narrative

  1. computeEngineeringFlowReport builds the deterministic JSON (KPIs as MetricNumber with current/previous/deltaPct/n/confidence, AI-owned + AI-touched cohorts, team health matrix, longestStuck, warnings).
  2. buildEvidencePack flattens the report into evidence facts (kpi.*, ai.owned.*, ai.touched.*, top_team.*, team_health.*, active_wait.*, intake_aging.*, data_quality.*).
  3. The variant-specific prompt + the evidence pack are sent to Azure OpenAI (gpt-5.4 via the v1 Responses API with strict Structured Outputs).
  4. A deterministic verifier validates that every evidenceIds citation in the narrative exists in the pack. If invalid, it retries once with the errors appended to the prompt; if still invalid, the deterministic narrative fallback kicks in.
  5. On success, the structured ReportNarrative JSON is wired into Page 1 of the PDF (with a small AI-narrated chip in the footer) and into the JSON response (narrative field).

Report variants

Same evidence pack, different writing instructions. All return the same ReportNarrative JSON shape so the PDF and Teams renderers can be variant-agnostic.

VariantAudienceLengthReasoning effort
leadership-brief (default)Exec leadership — 60-second Teams brief≈ 200 wordslow
executive-pdfPolished 4–6 page PDF for leadership review≈ 4 pagesmedium
diagnostic-fullTeam leads / Eng managers — detailed drill-down≈ 6+ pageshigh
ai-impact-deep-diveAI initiative — where AI helps / hurts≈ 3 pageshigh
bottleneck-hunterActive flow + intake aging detail≈ 2 pagesmedium
data-qualityTrust assessment — can leadership rely on the numbers?≈ 1 pagelow
team-comparisonTeams ranked, neutral language≈ 2 pagesmedium
meeting-prepTalking points for next meeting≈ 1 pagelow
fullEverything — kitchen sink≈ 8+ pageshigh

/overview AI brief banner

Sticky banner at the top of /overview rendering narrative.bottomLine + the top 4 keySignals for the current scope. Auto-refreshes when scope or range changes. Compact "AI brief unavailable" line + retry button when the AI service is off / down. Manual refresh button bypasses any cache.

Cohort and structural extensions

API endpoints

POST /api/reports/engineering-flow            # JSON report (full body + optional narrative)
POST /api/reports/engineering-flow/pdf        # PDF render (narrative wired into Page 1)
POST /api/reports/narrative                   # narrative-only (faster — used by the /overview banner)
POST /api/reports/parse-intent                # natural language → strict ReportRequest JSON
GET  /api/reports/snapshots                   # list persisted snapshots
GET  /api/reports/snapshots/[id]              # fetch one snapshot

Request body for the first three:
{
  "scope": { "projectKeys": ["AN","EVO",...], "name": "Leadership scope" },
  "rangeDays": 30,
  "variant": "leadership-brief",       // or any of the 9 variants above
  "depth": "brief" | "standard" | "deep",
  "language": "en" | "ru",
  "includeNarrative": true,             // default true; set false to skip the LLM
  "persist": false,                     // set true to save the snapshot for follow-ups
  "filters": { "issueType": "...", "priority": "..." }
}

Trust + fallback behaviour

Environment

AI_REPORTS_ENABLED=true
AZURE_OPENAI_ENDPOINT=https://<resource>.openai.azure.com
AZURE_OPENAI_API_KEY=<rotated>
AZURE_OPENAI_REPORT_MODEL=gpt-5.4
AZURE_OPENAI_API_VERSION=preview
AZURE_OPENAI_REASONING_EFFORT=low | medium | high
AZURE_OPENAI_VERBOSITY=low | medium | high
AZURE_OPENAI_MAX_OUTPUT_TOKENS=20000     # reasoning models burn many tokens internally
AZURE_OPENAI_TIMEOUT_MS=60000
AI_VERIFIER_ENABLED=true | false
AI_REPORT_CACHE_TTL_SEC=300              # safety net; primary cache is content-addressed
AI_REPORT_DEFAULT_VARIANT=leadership-brief
AI_REPORT_DEFAULT_LANGUAGE=en

Engineering Flow Report endpoints

Two POST endpoints that return the same report shape, one as JSON and one rendered to a 2-page A4 PDF (executive brief on page 1, detail tables on page 2). Both are deterministic from {scope, rangeDays, now}.

POST /api/reports/engineering-flow
POST /api/reports/engineering-flow/pdf

Body:
{
  "scope": { "projectKeys": ["AN","EVO","ADS"], "name": "Leadership scope" },
  "rangeDays": 30
}

Same compute as Overview — Issues done, AI Impact (with sample sizes), Top Teams, Where Work Waits, Agent Activity, AI Period rollup, Data Quality. No new metrics. Use the JSON endpoint when you want to embed the data into another system; use the PDF endpoint when you want a shareable artifact.


Questions? The repo lives at custom-agile-metrics-app. Specs and plans for ongoing work live under docs/superpowers/.