diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index e9d703eb..a3013115 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -799,6 +799,95 @@ .ta-bars { display: flex; flex-direction: column; gap: 6px; } +/* ---------- Chart help popovers ---------- + * Small info-icon button next to a chart title that toggles a popover + * with prose explaining what the chart shows. The wrapper is positioned + * (relative) so the popover anchors to the icon. Panels use + * `overflow: hidden` for clean borders, so the popover is positioned + * relatively but uses a high z-index and a wide box; it can extend + * below the panel head over the chart body (panels are tall enough). */ +.ta-help { + display: inline-flex; + align-items: center; + position: relative; + margin-left: 8px; + vertical-align: middle; +} +/* When a help popover is open, let it escape the panel's clipped frame + * (panels use overflow:hidden so their head background stays inside the + * rounded corners). :has() flips that off only for the open panel, so + * the popover can extend over the chart area below the head. */ +.ta-panel:has(.ta-help__pop) { overflow: visible; } +.ta-help__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--fg-muted); + cursor: pointer; + transition: + color var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + background var(--dur-fast) var(--ease); +} +.ta-help__btn:hover, +.ta-help__btn[aria-expanded="true"] { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-tint); +} +.ta-help__pop { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 30; + width: min(420px, 80vw); + padding: 14px 16px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-default, 4px); + box-shadow: var(--shadow-plate); + font-family: var(--font-ui); + font-size: 12.5px; + line-height: 1.55; + color: var(--fg); + text-transform: none; + letter-spacing: 0; + font-weight: 400; + font-style: normal; +} +.ta-help__pop-title { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-muted); + margin-bottom: 8px; +} +.ta-help__pop-body p { margin: 0 0 8px; } +.ta-help__pop-body p:last-child { margin-bottom: 0; } +.ta-help__pop-body ul { + margin: 0 0 8px; + padding-left: 18px; +} +.ta-help__pop-body li { margin-bottom: 4px; } +.ta-help__pop-body strong { color: var(--fg); font-weight: 600; } +.ta-help__pop-body em { color: var(--fg-subtle); font-style: italic; } + +/* KPI-strip hint row sits directly under the 5-tile grid and carries + * the help icon inline. Flex layout keeps the icon vertically centered + * against the small-caps hint text. */ +.ta-kpis-hint { + display: flex; + align-items: center; + gap: 8px; +} + /* Donut layout */ .ta-donut-row { display: flex; diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx index 24a14b16..3eeed9a7 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx @@ -34,7 +34,28 @@ import './TeamAnalytics.css'; // properties resolve at paint time against the active [data-theme] / // [data-mode], so dark mode and the Atlas theme need no per-mode // branching here. -const PILLAR_PALETTE = ['var(--accent)', 'var(--ocean)', 'var(--amber)', 'var(--forest)', 'var(--crimson)']; +// Categorical palette for pillar bands. The semantic theme tokens +// (--accent / --ocean / …) don't work here: in the default light theme +// --accent and --ocean both resolve to blue (first two slots collide), +// and the project routinely has more pillars than the 5-token semantic +// palette, so modulo wrapping causes explicit duplicates. These values +// are tuned for perceptual distance (Tableau-10 inspired) and survive +// the 0.85 opacity used by the stacked-area paint. Pillar colors carry +// no theme semantics — they're purely categorical codes — so fixed +// values are appropriate. Keep in sync with TeamAnalyticsDashboard.jsx. +const PILLAR_PALETTE = [ + '#4E79A7', // blue + '#F28E2B', // orange + '#59A14F', // green + '#E15759', // red + '#8E44AD', // purple + '#EDC948', // yellow + '#76B7B2', // teal + '#E377C2', // magenta + '#9C755F', // brown + '#17BECF', // cyan + '#7F7F7F', // gray +]; const formatHours = (h) => (h == null ? '—' : `${(+h).toFixed(1)}h`); const formatPct = (p) => (p == null || isNaN(p) ? '—' : `${Math.round(p)}%`); diff --git a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx index 15ded8fa..e0ec2c2b 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx @@ -18,9 +18,47 @@ * — no hex literals, no theme branching here. Light/Dark/Atlas swap * automatically. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { HelpCircle } from 'lucide-react'; import { contributionsAPI } from '../services/api'; +/* ChartHelp — small info-icon button that toggles a popover with prose + * explaining what a chart shows, how its metric is derived, and how to + * read it. Click-outside or Escape closes. Anchored to the panel head + * so the popover doesn't get clipped by the panel's overflow:hidden. */ +function ChartHelp({ title, children }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return undefined; + const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + return ( + + + {open && ( +
+
{title}
+
{children}
+
+ )} +
+ ); +} + const METRIC_OPTIONS = [ { val: 'prs', label: 'PRs merged' }, { val: 'opened', label: 'PRs opened' }, @@ -29,16 +67,42 @@ const METRIC_OPTIONS = [ { val: 'points', label: 'Story pts' }, ]; -// CSS-variable colors so light/dark/Atlas all resolve at paint time. +// Metric colors. Theme tokens don't work here for the same reason they +// don't for pillars — in the default light theme --accent and --ocean +// both resolve to blue, so the PRs-merged and PRs-opened lines on the +// Throughput trend (and the Opened/Merged bars on Inflow vs outflow) +// rendered identically. Absolute hex values from d3's schemeCategory10 +// guarantee five distinct hues across light / dark / Atlas modes. const METRIC_COLOR = { - prs: 'var(--accent)', - opened: 'var(--ocean)', - reviews: 'var(--amber)', - jira: 'var(--forest)', - points: 'var(--crimson)', + prs: '#1F77B4', // blue + opened: '#9467BD', // purple + reviews: '#FF7F0E', // orange + jira: '#2CA02C', // green + points: '#D62728', // red }; -const PILLAR_PALETTE = ['var(--accent)', 'var(--ocean)', 'var(--amber)', 'var(--forest)', 'var(--crimson)']; +// Categorical palette for pillar bands. Theme semantic tokens fall short +// here for two reasons: (1) in the default light theme --accent and +// --ocean both resolve to blue, so two pillars sharing the first two +// slots look identical; (2) the DEVOPS project routinely has 7+ pillars, +// past the 5-token semantic palette, and modulo wrapping forces explicit +// duplicates. These values are tuned for perceptual distance (Tableau-10 +// inspired) and survive the 0.85 opacity used by the stacked-area / +// donut paints. Fixed values are fine here because pillar colors carry +// no theme semantics — they're purely categorical codes. +const PILLAR_PALETTE = [ + '#4E79A7', // blue + '#F28E2B', // orange + '#59A14F', // green + '#E15759', // red + '#8E44AD', // purple — darkened from Tableau's muted #B07AA1 for stronger separation from magenta/pink slots + '#EDC948', // yellow + '#76B7B2', // teal + '#E377C2', // magenta — replaces Tableau pink #FF9DA7, which washed out next to orange at 0.85 opacity + '#9C755F', // brown + '#17BECF', // cyan + '#7F7F7F', // gray +]; const colorForPillar = (pillarName, allPillars) => { if (!pillarName || pillarName === 'Unassigned') return 'var(--fg-faint)'; const idx = (allPillars || []).indexOf(pillarName); @@ -474,7 +538,21 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick ))} -

Click a metric tile to drive the panels below.

+
+ Click a metric tile to drive the panels below. + +

Five clickable tiles, one per metric. Each tile shows the team‑wide total for the selected window and filter, plus a delta vs the prior half of the same window.

+ +

The arrow + percentage compares the most recent half of the window to the earlier half (e.g. for 26 weeks: last 13 vs prior 13). (new) means there was zero activity in the prior half.

+

Click any tile to make that metric the active metric — the Throughput trend highlights its line, and the Pillar mix, Donut, Top movers, and Ranking panels all switch to it.

+
+
@@ -482,6 +560,16 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick
Throughput trend · {METRIC_OPTIONS.find((o) => o.val === metric)?.label} + +

Weekly team‑wide totals for all five metrics on the same axis, so you can spot when activity surges, dips, or rotates between PR throughput, reviews, and Jira completion.

+
    +
  • Each line is the sum across the currently filtered members for that ISO week (Monday‑to‑Sunday, UTC).
  • +
  • The active metric's line is drawn bold with dots; the others are dimmed for reference. Click any tile or legend entry to switch which line is highlighted.
  • +
  • The Y axis auto‑scales to the largest series across all five metrics — so a small metric next to a large one will look flat. That's intentional: it preserves cross‑metric comparison.
  • +
  • X‑axis labels show MM‑DD of every 4th week plus the latest week.
  • +
+

Look for sustained slopes (capacity changes), notch‑shaped weeks (releases, holidays), and divergence between PRs opened and PRs merged (queue buildup vs catch‑up).

+
Team aggregate · weekly
@@ -504,7 +592,19 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick
-
Pillar mix over time
+
+ Pillar mix over time + +

Stacked area showing how the active metric is distributed across pillars, week by week. Each colored band is one pillar; the total height is the team aggregate for that week.

+
    +
  • A member is counted under their primary pillar — derived on the backend from the Jira components their work touches (with a fixVersion‑prefix fallback). Members with no signal yet show under Unassigned.
  • +
  • Bands only appear for pillars that actually contributed in the window; empty pillars are dropped.
  • +
  • Changing the active metric reshapes the whole chart — story points and PRs merged don't share scale.
  • +
  • The Pillar filter at the top still applies. With a single pillar selected, this becomes a single‑band area chart for that pillar's trend.
  • +
+

Use it to see whether a single pillar is carrying total throughput, or whether the mix is rotating across teams over time.

+
+
Stacked area · {METRIC_OPTIONS.find((o) => o.val === metric)?.label}
@@ -524,22 +624,45 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick
-
Inflow vs outflow
-
PRs opened (blue) vs PRs merged (red) · per week
+
+ Inflow vs outflow + +

Paired weekly bars: PRs opened (left, purple) vs PRs merged (right, blue). This panel is intentionally fixed to opened‑vs‑merged — it answers "is the team merging what it's opening?" and the question doesn't translate to other metrics.

+
    +
  • Opened > Merged for many weeks → queue is growing; PRs are piling up faster than they ship.
  • +
  • Merged > Opened → the team is catching up on an existing backlog, or shipping work opened earlier in the window.
  • +
  • Roughly balanced → steady state.
  • +
  • Both at zero → a quiet week (holiday, freeze, release pause).
  • +
+

Both bars respect the Window and Pillar filters but ignore the active metric selection.

+
+
+
PRs opened (purple) vs PRs merged (blue) · per week
+ colorA={METRIC_COLOR.opened} colorB={METRIC_COLOR.prs} />
-
Distribution by pillar
+
+ Distribution by pillar + +

Donut chart of the active metric summed over the whole window, sliced by pillar. The number in the center is the team total; each legend row shows the pillar's count and percent of total.

+
    +
  • Same pillar derivation as the Pillar mix chart — primary pillar per member, from Jira component matches with a fixVersion‑prefix fallback.
  • +
  • Pillars with zero contribution are omitted from both the donut and the legend.
  • +
  • Slices are sorted largest‑first so the biggest contributor is always at 12 o'clock.
  • +
+

Where Pillar mix over time shows when a pillar contributes, this shows how much in aggregate. Use it to sanity‑check whether one pillar dominates the metric you're looking at.

+
+
{METRIC_OPTIONS.find((o) => o.val === metric)?.label}
@@ -561,7 +684,20 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick
-
Top movers
+
+ Top movers + +

Per‑member delta on the active metric: the recent half of the window minus the prior half. Top 8 by absolute delta — biggest swings in either direction, not biggest totals.

+
    +
  • Bar length is normalized to the largest absolute delta in the list.
  • +
  • Numeric label: +N means the member's count grew compared to the prior half; a bare N (no sign) means it dropped; ± means it didn't move.
  • +
  • Bar color reflects direction — the active metric's color for growth, muted gray for a drop — so improvements and slowdowns are visually distinct.
  • +
  • Members with zero activity in both halves are excluded.
  • +
  • Click any row to jump to that member's profile.
  • +
+

Best read together with the ranking panel below: a member can rank near the top of the team yet have a negative mover delta (great in absolute terms, slowing down), or vice‑versa.

+
+
Recent half vs prior half · per-member delta
@@ -589,7 +725,19 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick
-
Per-member ranking
+
+ Per-member ranking + +

Every member who contributed at least one unit of the active metric in the window, sorted high to low. Bar length is the member's total; the number on the right is the raw count.

+
    +
  • The pillar tag next to each name uses the same pillar derivation as the Pillar mix and Donut charts. The bar is colored with that pillar's swatch, so the ranking doubles as a quick pillar‑contribution view.
  • +
  • Filtered by the Window and Pillar selectors at the top — pick a pillar to get a leaderboard within that pillar.
  • +
  • Members with zero activity for the active metric are hidden; switching to a different metric will reshuffle (and may shrink) the list.
  • +
  • Click a row to drill into that member's profile.
  • +
+

This is a totals view. For who's accelerating vs slowing down, use the Top movers panel above.

+
+
Selected window · {METRIC_OPTIONS.find((o) => o.val === metric)?.label} · click to drill into profile