From 28fe63630697ca7c232f89b46b6561c3fb218f49 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 04:18:14 +0000 Subject: [PATCH 1/7] feat(roadmap-planner): Dashboard tab + reshaped Member profile + prs_opened metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth tab to Team Analytics — a Dashboard that surfaces 180-day team activity through pre-baked aggregations the operator can drive with three filters (window 4/12/26 weeks, pillar, metric). Click any of the five KPI tiles (PRs merged / PRs opened / Reviews / Jira done / Story pts) to switch the highlighted line on the throughput trend, swap the metric on the pillar mix area, donut, top-movers bars, and per-member ranking. The Member profile tab is reshaped to match: drops the inline sidebar form, replaces it with a compact header carrying a [Switch member ▼] chip and a [✎ Edit identity] button — both open modal overlays with the same data the inline form had. Below the header sits the same five-tile KPI strip, the multi-line weekly contribution chart, and a 2×2 grid of Components touched, Rank vs team, Week-over-week pulse, and This sprint. Backend: new prs_opened weekly metric (migration 0004), aggregator pass mirroring prs_merged but bucketing by created_at, plumbed through MemberWeekRow / MemberSummary / Bucket so the dashboard's Inflow vs Outflow panel works without out-of-band sqlite reads. Theming: every visual var in the new code uses the existing semantic tokens (--bg-elevated, --accent, --ocean, --amber, --forest, --crimson, --shadow-plate, etc.), so light / dark / Atlas all swap without per-mode branching. Only hardcoded color is the modal scrim — intentional dark-with-alpha that works in both modes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/contributions/aggregator.go | 32 + .../backend/internal/contributions/service.go | 4 + .../backend/internal/storage/generic.go | 4 +- .../storage/migrations/0004_prs_opened.sql | 13 + .../backend/internal/storage/store.go | 1 + .../frontend/src/components/TeamAnalytics.css | 273 ++++++++ .../frontend/src/components/TeamAnalytics.jsx | 562 ++++++++++------ .../src/components/TeamAnalyticsDashboard.jsx | 606 ++++++++++++++++++ 8 files changed, 1295 insertions(+), 200 deletions(-) create mode 100644 roadmap-planner/backend/internal/storage/migrations/0004_prs_opened.sql create mode 100644 roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx diff --git a/roadmap-planner/backend/internal/contributions/aggregator.go b/roadmap-planner/backend/internal/contributions/aggregator.go index 1e5ca68b..6b317f69 100644 --- a/roadmap-planner/backend/internal/contributions/aggregator.go +++ b/roadmap-planner/backend/internal/contributions/aggregator.go @@ -131,6 +131,38 @@ func (a *Aggregator) Rebuild(ctx context.Context, from, to time.Time) error { return fmt.Errorf("aggregate PRs: %w", err) } + // PRs/MRs opened, by author × week. Mirrors the merged pass but + // buckets on created_at — the dashboard's Inflow vs Outflow panel + // reads opened-vs-merged together to spot review-queue buildup. + openedSQL := rebind(fmt.Sprintf(` + INSERT INTO member_week_metrics (member_id, week_start, pillar_id, component, prs_opened) + SELECT + COALESCE(m.id, pr.author_id) AS member_id, + %s AS week_start, + '' AS pillar_id, + '' AS component, + COUNT(*) AS prs_opened + FROM pull_requests pr + LEFT JOIN members m + ON (pr.source = 'github' + AND m.github_login IS NOT NULL + AND m.github_login <> '' + AND LOWER(m.github_login) = pr.author_login) + OR (pr.source = 'gitlab' + AND m.gitlab_username IS NOT NULL + AND m.gitlab_username <> '' + AND LOWER(m.gitlab_username) = pr.author_login) + WHERE COALESCE(m.id, pr.author_id) IS NOT NULL + AND pr.created_at >= ? + AND pr.created_at < ? + GROUP BY 1, 2 + ON CONFLICT(member_id, week_start, pillar_id, component) DO UPDATE SET + prs_opened = excluded.prs_opened`, dialect.WeekStart("pr.created_at"))) + + if _, err := db.ExecContext(ctx, openedSQL, from, to); err != nil { + return fmt.Errorf("aggregate opened PRs: %w", err) + } + // PRs/MRs reviewed, by reviewer × week. Same resolution priority as // the merge aggregation above — the JOIN branches on rv.source so // editing either github_login or gitlab_username retroactively diff --git a/roadmap-planner/backend/internal/contributions/service.go b/roadmap-planner/backend/internal/contributions/service.go index 49588b0f..e3309ab5 100644 --- a/roadmap-planner/backend/internal/contributions/service.go +++ b/roadmap-planner/backend/internal/contributions/service.go @@ -74,6 +74,7 @@ type MemberSummary struct { JiraIssuesDone int `json:"jira_issues_done"` JiraPointsDone float64 `json:"jira_points_done"` PRsMerged int `json:"prs_merged"` + PRsOpened int `json:"prs_opened"` PRsReviewed int `json:"prs_reviewed"` ReviewLatencyP50 float64 `json:"review_latency_p50_hours,omitempty"` Components []string `json:"components,omitempty"` @@ -86,6 +87,7 @@ type Bucket struct { JiraDone int `json:"jira_done"` Points float64 `json:"points"` PRsMerged int `json:"prs_merged"` + PRsOpened int `json:"prs_opened"` Reviews int `json:"reviews"` } @@ -110,6 +112,7 @@ func (s *Service) TeamOverview(ctx context.Context, q storage.MemberWeekQuery) ( ms.JiraIssuesDone += r.JiraIssuesDone ms.JiraPointsDone += r.JiraPointsDone ms.PRsMerged += r.PRsMerged + ms.PRsOpened += r.PRsOpened ms.PRsReviewed += r.PRsReviewed // We pick the latest non-nil latency seen as a representative; // proper aggregation across weeks happens in the Aggregator. @@ -126,6 +129,7 @@ func (s *Service) TeamOverview(ctx context.Context, q storage.MemberWeekQuery) ( bk.JiraDone += r.JiraIssuesDone bk.Points += r.JiraPointsDone bk.PRsMerged += r.PRsMerged + bk.PRsOpened += r.PRsOpened bk.Reviews += r.PRsReviewed } diff --git a/roadmap-planner/backend/internal/storage/generic.go b/roadmap-planner/backend/internal/storage/generic.go index 6db6b9db..ccbf88a6 100644 --- a/roadmap-planner/backend/internal/storage/generic.go +++ b/roadmap-planner/backend/internal/storage/generic.go @@ -320,7 +320,7 @@ func (s *genericStore) MemberWeekMetrics(ctx context.Context, q MemberWeekQuery) query := rebind(s.d, ` SELECT member_id, week_start, pillar_id, component, - jira_issues_done, jira_points_done, prs_merged, prs_reviewed, + jira_issues_done, jira_points_done, prs_merged, prs_opened, prs_reviewed, review_latency_p50_hours FROM member_week_metrics WHERE `+strings.Join(conds, " AND ")+` @@ -335,7 +335,7 @@ func (s *genericStore) MemberWeekMetrics(ctx context.Context, q MemberWeekQuery) var r MemberWeekRow var lat sql.NullFloat64 if err := rows.Scan(&r.MemberID, &r.WeekStart, &r.PillarID, &r.Component, - &r.JiraIssuesDone, &r.JiraPointsDone, &r.PRsMerged, &r.PRsReviewed, &lat); err != nil { + &r.JiraIssuesDone, &r.JiraPointsDone, &r.PRsMerged, &r.PRsOpened, &r.PRsReviewed, &lat); err != nil { return nil, err } if lat.Valid { diff --git a/roadmap-planner/backend/internal/storage/migrations/0004_prs_opened.sql b/roadmap-planner/backend/internal/storage/migrations/0004_prs_opened.sql new file mode 100644 index 00000000..5b75faa6 --- /dev/null +++ b/roadmap-planner/backend/internal/storage/migrations/0004_prs_opened.sql @@ -0,0 +1,13 @@ +-- ---------------------------------------------------------------------- +-- 0004_prs_opened — add a parallel "PRs opened" weekly metric so the +-- Dashboard tab's Inflow vs Outflow board, and the team table's PRs-opened +-- column, can read straight from member_week_metrics. Bucketed by +-- pull_requests.created_at (mirror of the existing prs_merged column, +-- which buckets by merged_at). +-- ---------------------------------------------------------------------- + +ALTER TABLE member_week_metrics ADD COLUMN prs_opened INTEGER NOT NULL DEFAULT 0; + +-- The aggregator's Rebuild pass populates this on next run; nothing to +-- backfill explicitly here. Existing rows pick up the DEFAULT 0 and +-- update on the next collection cycle's rebuild window. diff --git a/roadmap-planner/backend/internal/storage/store.go b/roadmap-planner/backend/internal/storage/store.go index 592c6ee1..13e63aaa 100644 --- a/roadmap-planner/backend/internal/storage/store.go +++ b/roadmap-planner/backend/internal/storage/store.go @@ -199,6 +199,7 @@ type MemberWeekRow struct { JiraIssuesDone int `json:"jira_issues_done"` JiraPointsDone float64 `json:"jira_points_done"` PRsMerged int `json:"prs_merged"` + PRsOpened int `json:"prs_opened"` PRsReviewed int `json:"prs_reviewed"` ReviewLatencyP50Hours *float64 `json:"review_latency_p50_hours,omitempty"` } diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index 8f407dd3..443439d8 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -691,4 +691,277 @@ .ta-row { grid-template-columns: 1fr; } .ta-profile-grid { grid-template-columns: 1fr; } .ta-sprint-grid { grid-template-columns: repeat(2, 1fr); } + .ta-dash-grid { grid-template-columns: 1fr !important; } + .ta-dash-grid__span { grid-column: 1 / -1 !important; } + .ta-kpis { grid-template-columns: repeat(2, 1fr); } } + +/* ---------- Dashboard tab ------------------------------------- */ + +.ta-toolbar { + display: flex; + gap: 14px; + align-items: center; + padding: 12px 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-default, 4px); + background: var(--bg-sunken); + margin-bottom: 18px; + flex-wrap: wrap; +} + +.ta-kpis { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 1px; + background: var(--border-soft); + border: 1px solid var(--border-soft); + border-radius: var(--radius-default, 4px); + overflow: hidden; + margin-bottom: 6px; +} +.ta-kpis-hint { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.12em; + color: var(--fg-muted); + margin: 0 0 14px; + text-transform: uppercase; +} + +.ta-kpis .ta-kpi { + background: var(--bg-elevated); + padding: 14px 18px; + text-align: left; + border: 0; + font: inherit; + color: inherit; + cursor: default; +} +.ta-kpi--btn { + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} +.ta-kpi--btn:hover { background: var(--bg-sunken); } +.ta-kpi--btn.is-active { + background: var(--accent-tint); +} +.ta-kpi--btn.is-active .ta-kpi__lbl { color: var(--accent); } + +.ta-kpi__delta { + font-family: var(--font-mono); + font-size: 11px; + margin-top: 2px; +} + +.ta-dash-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} +.ta-dash-grid__span { grid-column: 1 / -1; } + +/* Make .ta-legend items clickable when used as buttons (for legend-as-toggle). */ +.ta-legend__item { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: 0; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + opacity: 0.55; + letter-spacing: 0.06em; +} +.ta-legend__item:hover { opacity: 0.85; } +.ta-legend__item.is-active { opacity: 1; color: var(--fg); } +.ta-legend__item i { + display: inline-block; width: 10px; height: 10px; + border-radius: var(--radius-sm, 2px); +} + +/* Bar rows that double as drill-down buttons. */ +.ta-bar-row--btn { + width: 100%; + border: 0; + background: transparent; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + padding: 4px 6px; + border-radius: var(--radius-sm, 2px); + transition: background var(--dur-fast) var(--ease); +} +.ta-bar-row--btn:hover { background: var(--bg-sunken); } + +.ta-bars { display: flex; flex-direction: column; gap: 6px; } + +/* Donut layout */ +.ta-donut-row { + display: flex; + gap: 22px; + align-items: center; +} +.ta-donut-row svg { flex: 0 0 auto; } +.ta-donut-legend { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-subtle); +} +.ta-donut-legend .row { + display: grid; + grid-template-columns: 12px 1fr 50px 40px; + align-items: center; + gap: 8px; +} +.ta-donut-legend .row .lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ta-donut-legend .row .v { text-align: right; font-variant-numeric: tabular-nums; } +.ta-donut-legend .row .pct { text-align: right; color: var(--fg-muted); } +.ta-donut-legend .swatch { width: 10px; height: 10px; border-radius: var(--radius-sm, 2px); } + +/* ---------- Member Profile header (new shape) ---------- */ +.ta-prof-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-soft); + background: var(--bg-sunken); + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +.ta-avatar--lg { width: 40px; height: 40px; font-size: 13px; } +.ta-prof-name { + font-family: var(--font-display); + font-style: var(--display-style, normal); + font-weight: var(--display-weight, 600); + font-size: 22px; + margin: 0; + line-height: 1.1; + color: var(--fg); +} +.ta-prof-actions { + margin-left: auto; + display: flex; + gap: 8px; +} + +.ta-switcher-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-pill, 999px); + background: var(--bg-elevated); + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-subtle); + cursor: pointer; + letter-spacing: 0.04em; +} +.ta-switcher-chip:hover { + color: var(--fg); + border-color: var(--fg-faint); + background: var(--bg-sunken); +} +.ta-switcher-chip .caret { font-size: 9px; color: var(--fg-muted); } + +/* ---------- Modal ---------- */ +.ta-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: var(--z-modal, 1000); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 60px; + animation: ta-fade-in var(--dur, 200ms) var(--ease) both; +} +@keyframes ta-fade-in { from { opacity: 0; } to { opacity: 1; } } + +.ta-modal { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-default, 4px); + min-width: 420px; + max-width: 600px; + width: 100%; + max-height: 75vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: var(--shadow-plate); +} +.ta-modal__head { + padding: 14px 20px; + border-bottom: 1px solid var(--border-soft); + background: var(--bg-sunken); + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} +.ta-modal__title { + font-family: var(--font-display); + font-style: var(--display-style, normal); + font-weight: var(--display-weight, 600); + font-size: 18px; +} +.ta-modal__sub { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-muted); +} +.ta-modal__body { + padding: 18px 22px; + overflow-y: auto; + flex: 1; +} +.ta-modal__foot { + padding: 14px 20px; + border-top: 1px solid var(--border-soft); + display: flex; + gap: 8px; + justify-content: flex-end; + background: var(--bg-sunken); + align-items: center; +} + +.ta-btn--ghost { + background: transparent; + border: 0; + color: var(--fg-muted); +} +.ta-btn--ghost:hover { color: var(--fg); background: transparent; } + +.ta-picker-list { display: flex; flex-direction: column; gap: 0; } +.ta-picker-item { + display: grid; + grid-template-columns: 1fr 60px; + gap: 10px; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid var(--border-soft); + background: transparent; + cursor: pointer; + align-items: center; + text-align: left; + font: inherit; + color: inherit; + width: 100%; + transition: background var(--dur-fast) var(--ease); +} +.ta-picker-item:hover { background: var(--bg-sunken); } +.ta-picker-item.is-active { background: var(--accent-tint); } +.ta-picker-item.is-active .ta-name { color: var(--accent); } diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx index 0cc6aa84..4c9edb62 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx @@ -23,20 +23,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { contributionsAPI, handleAPIError, roadmapAPI } from '../services/api'; +import DashboardView from './TeamAnalyticsDashboard'; import './TeamAnalytics.css'; /* -------------------------------------------------------------------- */ /* Generic helpers */ /* -------------------------------------------------------------------- */ -// Theme-token series colors. CSS custom properties resolve at paint-time -// against the active [data-theme]/[data-mode], so dark mode + Atlas -// theme just work without per-mode branching here. -const SERIES_COLOR = { - prs: 'var(--accent)', - jira: 'var(--ocean)', - reviews: 'var(--amber)', -}; +// Theme-token palette for the pillar throughput stack. CSS custom +// 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)']; const formatHours = (h) => (h == null ? '—' : `${(+h).toFixed(1)}h`); @@ -109,24 +106,25 @@ const colorForPillar = (pillarName, allPillars) => { /* URL state — ?tab=&member= */ /* -------------------------------------------------------------------- */ -const VALID_TABS = new Set(['team', 'member', 'slice']); +const VALID_TABS = new Set(['dashboard', 'team', 'member', 'slice']); +const DEFAULT_TAB = 'dashboard'; const readURLState = () => { - if (typeof window === 'undefined') return { tab: 'team', id: '' }; + if (typeof window === 'undefined') return { tab: DEFAULT_TAB, id: '' }; try { const p = new URLSearchParams(window.location.search); let tab = p.get('tab') || ''; const id = p.get('member') || p.get('id') || ''; - if (!VALID_TABS.has(tab)) tab = id ? 'member' : 'team'; + if (!VALID_TABS.has(tab)) tab = id ? 'member' : DEFAULT_TAB; return { tab, id }; } catch { - return { tab: 'team', id: '' }; + return { tab: DEFAULT_TAB, id: '' }; } }; const writeURLState = ({ tab, id }) => { if (typeof window === 'undefined') return; try { const url = new URL(window.location.href); - if (tab && tab !== 'team') url.searchParams.set('tab', tab); + if (tab && tab !== DEFAULT_TAB) url.searchParams.set('tab', tab); else url.searchParams.delete('tab'); if (id) url.searchParams.set('member', id); else url.searchParams.delete('member'); @@ -306,65 +304,6 @@ function NetworkPanel({ network }) { ); } -/* -------------------------------------------------------------------- */ -/* Multi-line trend chart */ -/* -------------------------------------------------------------------- */ - -function TrendChart({ weeks }) { - if (!weeks || weeks.length === 0) { - return

No activity in window.

; - } - const W = 720, H = 220, pad = { l: 40, r: 12, t: 14, b: 30 }; - const allMax = Math.max(1, ...weeks.flatMap((w) => [w.jira_done || 0, w.prs_merged || 0, w.reviews || 0])); - const xStep = (W - pad.l - pad.r) / Math.max(1, weeks.length - 1); - const yOf = (v) => H - pad.b - (v / allMax) * (H - pad.t - pad.b); - const xOf = (i) => pad.l + i * xStep; - - const lines = [ - { key: 'jira_done', color: SERIES_COLOR.jira }, - { key: 'reviews', color: SERIES_COLOR.reviews }, - { key: 'prs_merged', color: SERIES_COLOR.prs }, - ]; - - return ( - <> - - {[0, 1, 2, 3, 4].map((g) => { - const y = pad.t + (H - pad.t - pad.b) * (g / 4); - const v = Math.round(allMax * (1 - g / 4)); - return ( - - - {v} - - ); - })} - {weeks.map((w, i) => ( - w{i + 1} - ))} - {lines.map((ln) => { - const pts = weeks.map((w, i) => `${xOf(i)},${yOf(w[ln.key] || 0)}`); - return ( - - - {pts.map((p, i) => { - const [cx, cy] = p.split(','); - return ; - })} - - ); - })} - -
- PRs merged - Jira done - Reviews -
- - ); -} - /* -------------------------------------------------------------------- */ /* Status badge */ /* -------------------------------------------------------------------- */ @@ -547,8 +486,8 @@ export default function TeamAnalytics() { const goToTab = useCallback((next, id = selectedID) => { if (next === 'member' && !id) { - setTab('team'); - writeURLState({ tab: 'team', id: '' }); + setTab(DEFAULT_TAB); + writeURLState({ tab: DEFAULT_TAB, id: '' }); return; } setTab(next); @@ -586,12 +525,19 @@ export default function TeamAnalytics() { @@ -620,6 +566,14 @@ export default function TeamAnalytics() { )} + {!empty && tab === 'dashboard' && ( + + )} + {!empty && tab === 'team' && ( { @@ -880,158 +855,348 @@ function MemberView({ memberRow, onBack, onSaved, allRows, orderedPillarNames, o ? 'derived from components' : 'no match'; + /* ---- new-graphs derived data ---- */ + const half = Math.floor(weeks.length / 2); + const recentWks = weeks.slice(half); + const priorWks = weeks.slice(0, half); + const sumOver = (arr, key) => arr.reduce((acc, w) => acc + (w ? (w[key] || 0) : 0), 0); + + // Rank vs team — same metric across all members in the prefetched roster. + const rankRows = MEMBER_METRICS.map((m) => { + const mySum = sumOver(weeks, m.key); + const allSums = (allRows || []).map((r) => (r.week_totals || []).reduce((a, w) => a + (w[m.key] || 0), 0)); + const max = Math.max(1, ...allSums); + const sorted = [...allSums].sort((a, b) => b - a); + const rank = sorted.indexOf(mySum) + 1; + return { ...m, mySum, rank, total: allSums.length, max }; + }); + + // Pulse: prior-half vs recent-half per metric. + const pulseRows = MEMBER_METRICS.map((m) => { + const r = sumOver(recentWks, m.key); + const p = sumOver(priorWks, m.key); + const max = Math.max(1, r, p); + return { ...m, prior: p, recent: r, max }; + }); + + // KPI tiles — use detail rollups when present, otherwise fall back to + // summing the weekly array (so prs_opened works even if memberRow's + // legacy fields haven't been populated yet). + const kpiTotals = { + prs_merged: detail?.prs_merged ?? totals.prs ?? sumOver(weeks, 'prs_merged'), + prs_opened: detail?.prs_opened ?? sumOver(weeks, 'prs_opened'), + reviews: detail?.prs_reviewed ?? totals.reviews ?? sumOver(weeks, 'reviews'), + jira_done: detail?.jira_issues_done ?? totals.jira ?? sumOver(weeks, 'jira_done'), + points: detail?.jira_points_done ?? totals.points ?? sumOver(weeks, 'points'), + }; + + const filteredRoster = sortedRoster.filter((r) => { + if (!switchFilter) return true; + const f = switchFilter.toLowerCase(); + return r.name.toLowerCase().includes(f) + || (r.id || '').toLowerCase().includes(f) + || (r.pillar || '').toLowerCase().includes(f); + }); + return ( <> -
- -

Weekly contribution - PRs merged - Jira done - Reviews + {MEMBER_METRICS.map((m) => ( + + ))}

-
Last {weeks.length || 12} weeks · counts per ISO week
- +
+ Last {weeks.length || 12} weeks · counts per ISO week · + highlighted: {MEMBER_METRICS.find((m) => m.key === activeMetric)?.label} +
+
-
-

Components touched - Each row = one component · width = issues -

-
Where this member spent their work in the window
- {components.length === 0 ? ( -

No components recorded for this member yet — Jira issues need a Component value.

- ) : ( - components.map((c) => ( -
-
{c.component}
+
+
+

Components touched + Each row = one component · width = issues +

+
Where this member spent their work in the window
+ {components.length === 0 ? ( +

No components recorded for this member yet — Jira issues need a Component value.

+ ) : ( + components.map((c) => ( +
+
{c.component}
+
+
+
+
{c.issues}
+
+ )) + )} +
+ +
+

Rank vs team

+
Where this member sits on each metric
+ {rankRows.map((r) => ( +
+
{r.label}
-
+
-
{c.issues}
+
#{r.rank}/{r.total}
- )) - )} -
+ ))} +
-
-

This sprint - - {sprint?.name || '—'} - -

-
Live · refreshes with the next collection cycle
-
-
In progress
{sprint?.wip ?? '—'}
-
Done
{sprint?.done ?? '—'}
-
PRs open
{sprint?.prs_open ?? '—'}
-
PRs merged
{sprint?.prs_merged ?? '—'}
+
+

Week-over-week pulse

+
Recent half vs prior half · per metric
+ {pulseRows.map((p) => ( +
+
{p.label}
+
+
+
+
+
{p.prior}→{p.recent}
+
+ ))}
-
-
-

Identity - edit · save triggers an aggregator rebuild -

-
Map this member to a GitHub login + (optional) pillar override
-
- - - - - - +
+

This sprint + + {sprint?.name || '—'} + +

+
Live · refreshes with the next collection cycle
+
+
In progress
{sprint?.wip ?? '—'}
+
Done
{sprint?.done ?? '—'}
+
PRs open
{sprint?.prs_open ?? '—'}
+
PRs merged
{sprint?.prs_merged ?? '—'}
+
-
- {loading && Loading…} - {saving && Saving…} - - +
+
+
+ + {showSwitch && ( +
setShowSwitch(false)}> +
e.stopPropagation()}> +
+
+
Switch member
+
Jump straight to another profile
+
+ +
+
+ setSwitchFilter(e.target.value)} + style={{ width: '100%', marginBottom: 12 }} /> +
+ {filteredRoster.length === 0 ? ( +

No matches.

+ ) : filteredRoster.map((r) => ( + + ))} +
+
+ )} -
-

Switch member

-
Jump straight to another profile without going back
- + {showEdit && ( +
setShowEdit(false)}> +
e.stopPropagation()}> +
+
+
Edit identity
+
Save triggers an aggregator rebuild
+
+ +
+
+
+ + + + + + +
+
+
+ {loading && Loading…} + {saving && Saving…} + + +
-
+ )} ); } +/* -------------------------------------------------------------------- */ +/* MemberMultiLine — five-metric overlay with a highlighted line. */ +/* -------------------------------------------------------------------- */ + +function MemberMultiLine({ weeks, highlight }) { + if (!weeks || weeks.length === 0) { + return

No activity in window.

; + } + const W = 720, H = 220, pad = { l: 40, r: 12, t: 14, b: 30 }; + const series = MEMBER_METRICS.map((m) => ({ + ...m, + values: weeks.map((w) => w[m.key] || 0), + })); + const allMax = Math.max(1, ...series.flatMap((s) => s.values)); + const xStep = (W - pad.l - pad.r) / Math.max(1, weeks.length - 1); + const yOf = (v) => H - pad.b - (v / allMax) * (H - pad.t - pad.b); + const xOf = (i) => pad.l + i * xStep; + return ( + + {[0, 1, 2, 3, 4].map((g) => { + const y = pad.t + (H - pad.t - pad.b) * (g / 4); + const v = Math.round(allMax * (1 - g / 4)); + return ( + + + {v} + + ); + })} + {weeks.map((_, i) => ( + (i % 4 === 0 || i === weeks.length - 1) && ( + w{i + 1} + ) + ))} + {series.map((s) => { + const isHi = !highlight || s.key === highlight; + const op = highlight && !isHi ? 0.18 : 0.95; + const sw = isHi ? 1.9 : 1.1; + const pts = s.values.map((v, i) => `${xOf(i)},${yOf(v)}`); + return ( + + + {isHi && pts.map((p, i) => { + const [cx, cy] = p.split(','); + return ; + })} + + ); + })} + + ); +} + /* -------------------------------------------------------------------- */ /* Slice Explorer tab */ /* -------------------------------------------------------------------- */ @@ -1166,7 +1331,8 @@ function SliceView({ rows, orderedPillarNames, pillarBuckets }) { const max = Math.max(1, ...cells); const total = cells.reduce((a, b) => a + b, 0); return { rowKey: rk, cells, max, total }; - }), [rowKeys, cols, rows, rowsAxis, colsAxis, metric, seriesByMember, seriesByPillar, allWeeks, metricGetter]); + // metric is captured by metricGetter, which is itself a dep below. + }), [rowKeys, cols, rows, rowsAxis, colsAxis, seriesByMember, seriesByPillar, allWeeks, metricGetter]); const labelOfRow = (rk) => { if (rowsAxis === 'member') { diff --git a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx new file mode 100644 index 00000000..fc198335 --- /dev/null +++ b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx @@ -0,0 +1,606 @@ +/* + * Team Analytics — Dashboard tab. + * + * A single screen of pre-baked aggregations driven by three filters: + * • Window (4 / 12 / 26 weeks) + * • Pillar (All / each configured pillar / Unassigned) + * • Metric (PRs merged / PRs opened / Reviews / Jira done / Story pts) + * + * The Metric filter is fronted by 5 clickable KPI tiles. The "active" + * metric drives the Throughput trend's highlighted line, the Pillar mix + * over time, the donut, the Top movers, and the per-member ranking. + * + * The Inflow vs outflow panel is intentionally fixed to opened-vs-merged + * since that's the question it answers — flipping it to "Reviews opened + * vs merged" makes no sense. + * + * Theming: every visual var comes from styles/theme.css semantic tokens + * — no hex literals, no theme branching here. Light/Dark/Atlas swap + * automatically. + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { contributionsAPI } from '../services/api'; + +const METRIC_OPTIONS = [ + { val: 'prs', label: 'PRs merged' }, + { val: 'opened', label: 'PRs opened' }, + { val: 'reviews', label: 'Reviews' }, + { val: 'jira', label: 'Jira done' }, + { val: 'points', label: 'Story pts' }, +]; + +// CSS-variable colors so light/dark/Atlas all resolve at paint time. +const METRIC_COLOR = { + prs: 'var(--accent)', + opened: 'var(--ocean)', + reviews: 'var(--amber)', + jira: 'var(--forest)', + points: 'var(--crimson)', +}; + +const PILLAR_PALETTE = ['var(--accent)', 'var(--ocean)', 'var(--amber)', 'var(--forest)', 'var(--crimson)']; +const colorForPillar = (pillarName, allPillars) => { + if (!pillarName || pillarName === 'Unassigned') return 'var(--fg-faint)'; + const idx = (allPillars || []).indexOf(pillarName); + if (idx >= 0) return PILLAR_PALETTE[idx % PILLAR_PALETTE.length]; + let h = 0; + for (let i = 0; i < pillarName.length; i++) h = (h * 31 + pillarName.charCodeAt(i)) >>> 0; + return PILLAR_PALETTE[h % PILLAR_PALETTE.length]; +}; + +const fmt = (v) => { + const n = +v || 0; + return Number.isInteger(n) ? n : n.toFixed(1); +}; + +// metricOf reads the configured metric off a Bucket from /api/contributions/team. +// Bucket fields use the JSON names: jira_done, points, prs_merged, prs_opened, reviews. +const metricOf = (bucket, metric) => { + if (!bucket) return 0; + if (metric === 'prs') return bucket.prs_merged || 0; + if (metric === 'opened') return bucket.prs_opened || 0; + if (metric === 'reviews') return bucket.reviews || 0; + if (metric === 'jira') return bucket.jira_done || 0; + if (metric === 'points') return bucket.points || 0; + return 0; +}; + +const memberSum = (member, weeks, metric) => + weeks.reduce((acc, w) => { + const b = (member.week_totals || []).find((x) => x.week_start === w); + return acc + metricOf(b, metric); + }, 0); + +const teamSum = (members, weeks, metric) => + members.reduce((acc, m) => acc + memberSum(m, weeks, metric), 0); + +const pillarOf = (member) => (member.pillars && member.pillars[0]) || member.pillar || 'Unassigned'; + +/* ---------------- date helpers ---------------- */ +function isoDate(d) { return new Date(d).toISOString().slice(0, 10); } +function mondayOf(d) { + const x = new Date(d); + const day = x.getUTCDay(); + const diff = (day === 0 ? -6 : 1 - day); + x.setUTCDate(x.getUTCDate() + diff); + x.setUTCHours(0, 0, 0, 0); + return x; +} +function lastNWeeks(n) { + const today = new Date(); + const lastMonday = mondayOf(today); + const out = []; + for (let i = n - 1; i >= 0; i--) { + const d = new Date(lastMonday); + d.setUTCDate(d.getUTCDate() - i * 7); + out.push(isoDate(d)); + } + return out; +} + +/* ---------------- chart primitives ---------------- */ + +// LineChart — multi-line overlay, optional highlight key fades others. +function LineChart({ weeks, seriesByMetric, metricKeys, highlight, height = 220 }) { + const W = 880, H = height, pad = { l: 44, r: 14, t: 14, b: 28 }; + const allMax = Math.max(1, ...metricKeys.flatMap((k) => seriesByMetric[k] || [])); + const xStep = (W - pad.l - pad.r) / Math.max(1, weeks.length - 1); + return ( + + {[0, 1, 2, 3, 4].map((g) => { + const y = pad.t + (H - pad.t - pad.b) * (g / 4); + const v = Math.round(allMax * (1 - g / 4)); + return ( + + + {v} + + ); + })} + {weeks.map((wk, i) => ( + (i % 4 === 0 || i === weeks.length - 1) && ( + {wk.slice(5)} + ) + ))} + {metricKeys.map((k) => { + const series = seriesByMetric[k] || []; + const isHi = !highlight || k === highlight; + const op = highlight && !isHi ? 0.18 : 0.95; + const sw = isHi ? 2.0 : 1.2; + const pts = series.map((v, i) => `${pad.l + i * xStep},${H - pad.b - (v / allMax) * (H - pad.t - pad.b)}`).join(' '); + return ( + + + {isHi && series.map((v, i) => { + const cx = pad.l + i * xStep; + const cy = H - pad.b - (v / allMax) * (H - pad.t - pad.b); + return ; + })} + + ); + })} + + ); +} + +// StackedAreaChart — cumulative areas, one polygon per category. +function StackedAreaChart({ weeks, stackByCategory, categories, colorMap, height = 220 }) { + const W = 600, H = height, pad = { l: 44, r: 14, t: 14, b: 28 }; + const sums = weeks.map((_, i) => categories.reduce((a, c) => a + ((stackByCategory[c] || [])[i] || 0), 0)); + const max = Math.max(1, ...sums); + const xStep = (W - pad.l - pad.r) / Math.max(1, weeks.length - 1); + const offsets = weeks.map(() => H - pad.b); + return ( + + {[0, 1, 2, 3, 4].map((g) => { + const y = pad.t + (H - pad.t - pad.b) * (g / 4); + const v = Math.round(max * (1 - g / 4)); + return ( + + + {v} + + ); + })} + {weeks.map((wk, i) => ( + (i % 4 === 0 || i === weeks.length - 1) && ( + {wk.slice(5)} + ) + ))} + {categories.map((cat) => { + const series = stackByCategory[cat] || weeks.map(() => 0); + const top = series.map((v, i) => { + const h = (v / max) * (H - pad.t - pad.b); + offsets[i] -= h; + return `${pad.l + i * xStep},${offsets[i]}`; + }); + const bot = series.map((v, i) => `${pad.l + i * xStep},${offsets[i] + (v / max) * (H - pad.t - pad.b)}`).reverse(); + return ; + })} + + ); +} + +// GroupedBarChart — pairs of bars per week. +function GroupedBarChart({ weeks, seriesA, seriesB, labelA, labelB, colorA, colorB, height = 220 }) { + const W = 600, H = height, pad = { l: 44, r: 14, t: 14, b: 36 }; + const max = Math.max(1, ...seriesA, ...seriesB); + const xStep = (W - pad.l - pad.r) / weeks.length; + const bw = Math.max(2, (xStep - 4) / 2); + return ( + + {[0, 1, 2, 3, 4].map((g) => { + const y = pad.t + (H - pad.t - pad.b) * (g / 4); + const v = Math.round(max * (1 - g / 4)); + return ( + + + {v} + + ); + })} + {weeks.map((wk, i) => { + const x0 = pad.l + i * xStep + 2; + const ha = (seriesA[i] / max) * (H - pad.t - pad.b); + const hb = (seriesB[i] / max) * (H - pad.t - pad.b); + return ( + + + + {(i % 4 === 0 || i === weeks.length - 1) && ( + {wk.slice(5)} + )} + + ); + })} + + + {labelA} + + {labelB} + + + ); +} + +// DonutChart — proportions of a single metric by pillar. +function DonutChart({ slices }) { + const cx = 90, cy = 90, r = 70, ir = 42; + const total = slices.reduce((a, s) => a + s.value, 0); + if (total === 0) { + return ( + + no data + + ); + } + let acc = 0; + return ( + + {slices.map((s, i) => { + const start = (acc / total) * Math.PI * 2 - Math.PI / 2; + acc += s.value; + const end = (acc / total) * Math.PI * 2 - Math.PI / 2; + const large = (end - start) > Math.PI ? 1 : 0; + const sx = cx + Math.cos(start) * r, sy = cy + Math.sin(start) * r; + const ex = cx + Math.cos(end) * r, ey = cy + Math.sin(end) * r; + const isx = cx + Math.cos(end) * ir, isy = cy + Math.sin(end) * ir; + const iex = cx + Math.cos(start) * ir, iey = cy + Math.sin(start) * ir; + return ( + + ); + })} + {fmt(total)} + TOTAL + + ); +} + +/* ---------------- main view ---------------- */ + +export default function DashboardView({ rows, orderedPillarNames, onMemberClick }) { + const [windowWeeks, setWindowWeeks] = useState(26); + const [metric, setMetric] = useState('prs'); + const [pillarFilter, setPillarFilter] = useState('all'); + + // The default /api/contributions/team window is 12 weeks; for the + // Dashboard's wider windows we re-fetch with explicit from/to. + const [team, setTeam] = useState(rows); + const [loading, setLoading] = useState(false); + useEffect(() => { + let cancelled = false; + const refetch = async () => { + const today = new Date(); + const to = isoDate(today); + const fromDate = new Date(today); + fromDate.setUTCDate(fromDate.getUTCDate() - windowWeeks * 7); + const from = isoDate(fromDate); + setLoading(true); + try { + const resp = await contributionsAPI.team({ from, to }); + if (cancelled) return; + // Merge week_totals from the new fetch onto the rows we already + // have (which carry display fields like name/pillars/github). + const byID = new Map((resp.members || []).map((t) => [t.member_id, t])); + const merged = (rows || []).map((r) => { + const t = byID.get(r.id); + return t ? { ...r, week_totals: t.week_totals || [], pillars: t.pillars || r.pillars } : r; + }); + // Surface members that exist in the rollup but not yet in the + // members directory (rare — happens before first PATCH). + (resp.members || []).forEach((t) => { + if (!merged.find((r) => r.id === t.member_id)) { + merged.push({ + id: t.member_id, name: t.member_id, github: '', gitlab: '', + pillars: t.pillars || [], pillar: (t.pillars || [])[0] || '', + week_totals: t.week_totals || [], active: true, + }); + } + }); + setTeam(merged); + } catch { + if (!cancelled) setTeam(rows); // fall back to props + } finally { + if (!cancelled) setLoading(false); + } + }; + refetch(); + return () => { cancelled = true; }; + }, [windowWeeks, rows]); + + const weeks = useMemo(() => lastNWeeks(windowWeeks), [windowWeeks]); + + const pillarKeys = useMemo(() => [...orderedPillarNames, 'Unassigned'], [orderedPillarNames]); + const pillarColor = useMemo(() => { + const map = {}; + pillarKeys.forEach((p) => { map[p] = colorForPillar(p, orderedPillarNames); }); + return map; + }, [pillarKeys, orderedPillarNames]); + + const filteredMembers = useMemo(() => { + if (pillarFilter === 'all') return team; + if (pillarFilter === 'Unassigned') return team.filter((m) => !m.pillars || m.pillars.length === 0); + return team.filter((m) => (m.pillars || []).includes(pillarFilter)); + }, [team, pillarFilter]); + + /* ---- KPIs (each metric vs prior half) ---- */ + const half = Math.floor(weeks.length / 2); + const recentWks = weeks.slice(half); + const priorWks = weeks.slice(0, half); + const kpis = METRIC_OPTIONS.map((opt) => { + const tot = teamSum(filteredMembers, weeks, opt.val); + const rec = teamSum(filteredMembers, recentWks, opt.val); + const pri = teamSum(filteredMembers, priorWks, opt.val); + let delta = null; + if (pri > 0) { + const pct = Math.round(((rec - pri) / pri) * 100); + delta = { kind: Math.abs(pct) < 5 ? 'flat' : (pct > 0 ? 'up' : 'down'), pct: Math.abs(pct) }; + } else if (rec > 0) { + delta = { kind: 'up', pct: 100, novel: true }; + } + return { ...opt, total: tot, delta }; + }); + + /* ---- Trend (multi-line) ---- */ + const trendSeries = useMemo(() => { + const out = {}; + METRIC_OPTIONS.forEach((opt) => { + out[opt.val] = weeks.map((w) => teamSum(filteredMembers, [w], opt.val)); + }); + return out; + }, [filteredMembers, weeks]); + + /* ---- Pillar mix over time (stacked area) ---- */ + const pillarStack = useMemo(() => { + const stack = {}; + pillarKeys.forEach((p) => { stack[p] = weeks.map(() => 0); }); + filteredMembers.forEach((m) => { + const p = pillarOf(m); + weeks.forEach((wk, i) => { + const b = (m.week_totals || []).find((x) => x.week_start === wk); + stack[p][i] += metricOf(b, metric); + }); + }); + const used = pillarKeys.filter((p) => stack[p].some((v) => v > 0)); + return { stack, used }; + }, [filteredMembers, weeks, metric, pillarKeys]); + + /* ---- Inflow vs Outflow (opened vs merged) ---- */ + const flowOpened = weeks.map((w) => teamSum(filteredMembers, [w], 'opened')); + const flowMerged = weeks.map((w) => teamSum(filteredMembers, [w], 'prs')); + + /* ---- Donut (selected metric, by pillar) ---- */ + const donutSlices = useMemo(() => { + const totals = {}; + pillarKeys.forEach((p) => { totals[p] = 0; }); + filteredMembers.forEach((m) => { totals[pillarOf(m)] += memberSum(m, weeks, metric); }); + return pillarKeys + .map((p) => ({ label: p, value: totals[p], color: pillarColor[p] })) + .filter((s) => s.value > 0) + .sort((a, b) => b.value - a.value); + }, [filteredMembers, weeks, metric, pillarKeys, pillarColor]); + const donutTotal = donutSlices.reduce((a, s) => a + s.value, 0); + + /* ---- Top movers (recent half vs prior half) ---- */ + const movers = useMemo(() => { + return filteredMembers + .map((m) => { + const r = memberSum(m, recentWks, metric); + const p = memberSum(m, priorWks, metric); + return { id: m.id, name: m.name, recent: r, prior: p, delta: r - p }; + }) + .filter((r) => r.recent + r.prior > 0) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 8); + }, [filteredMembers, metric, recentWks, priorWks]); + const moversMax = Math.max(1, ...movers.map((r) => Math.abs(r.delta))); + + /* ---- Per-member ranking ---- */ + const ranking = useMemo(() => { + return filteredMembers + .map((m) => ({ id: m.id, name: m.name, pillar: pillarOf(m), val: memberSum(m, weeks, metric) })) + .filter((r) => r.val > 0) + .sort((a, b) => b.val - a.val); + }, [filteredMembers, weeks, metric]); + const rankingMax = Math.max(1, ...ranking.map((r) => r.val)); + + return ( + <> +
+ Window +
+ {[4, 12, 26].map((w) => ( + + ))} +
+ Pillar +
+ + {orderedPillarNames.map((p) => ( + + ))} + +
+
+ +
+ {kpis.map((k) => ( + + ))} +
+

Click a metric tile to drive the panels below.

+ +
+
+
+
+
+ Throughput trend · {METRIC_OPTIONS.find((o) => o.val === metric)?.label} +
+
Team aggregate · weekly
+
+
+ {METRIC_OPTIONS.map((o) => ( + + ))} +
+
+
+ o.val)} highlight={metric} /> +
+
+ +
+
+
+
Pillar mix over time
+
Stacked area · {METRIC_OPTIONS.find((o) => o.val === metric)?.label}
+
+
+
+ +
+ {pillarStack.used.map((p) => ( + {p} + ))} +
+
+
+ +
+
+
+
Inflow vs outflow
+
PRs opened (blue) vs PRs merged (red) · per week
+
+
+
+ +
+
+ +
+
+
+
Distribution by pillar
+
{METRIC_OPTIONS.find((o) => o.val === metric)?.label}
+
+
+
+ +
+ {donutSlices.map((s) => ( +
+ + {s.label} + {fmt(s.value)} + {donutTotal > 0 ? Math.round((s.value / donutTotal) * 100) : 0}% +
+ ))} +
+
+
+ +
+
+
+
Top movers
+
Recent half vs prior half · per-member delta
+
+
+
+
+ {movers.length === 0 ? ( +

No mover data in this window.

+ ) : movers.map((r) => { + const w = (Math.abs(r.delta) / moversMax) * 100; + const sign = r.delta > 0 ? '+' : (r.delta < 0 ? '' : '±'); + const color = r.delta >= 0 ? METRIC_COLOR[metric] : 'var(--fg-muted)'; + return ( + + ); + })} +
+
+
+ +
+
+
+
Per-member ranking
+
Selected window · {METRIC_OPTIONS.find((o) => o.val === metric)?.label} · click to drill into profile
+
+
+
+
+ {ranking.length === 0 ? ( +

No activity for this metric in the window.

+ ) : ranking.map((r) => { + const w = (r.val / rankingMax) * 100; + return ( + + ); + })} +
+
+
+
+ + {loading &&
Refreshing…
} + + ); +} From c7e61d59f78360049e34ac21de36a19fbde94add Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 05:24:05 +0000 Subject: [PATCH 2/7] fix(roadmap-planner): dashboard crash when a member's pillar isn't pre-keyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pillarKeys = [...orderedPillarNames, "Unassigned"]` is built from the configured/derived pillar list, but team rollups can carry a member whose primary pillar isn't in that list (e.g. when basic data hasn't loaded yet, or the configured `team_analytics.pillars` order is empty and a member's pillar comes from the per-member attribution chain). `stack[p][i] += …` in pillarStack and `totals[p] += …` in donutSlices both crashed with `Cannot read properties of undefined (reading '0')` on those rows. Lazily seed both maps so an unmapped pillar still shows up rather than throwing. Also normalize `week_start` comparison: API returns the full ISO timestamp (`"2025-11-03T00:00:00Z"`), the dashboard's weeks array uses 10-char dates (`"2025-11-03"`). Pre-fix, no buckets ever matched → charts rendered as flat zeros even when data was present. Includes a regression smoke test that mounts DashboardView with `orderedPillarNames=[]` and members carrying real pillars. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/TeamAnalytics.smoke.test.jsx | 98 +++++++++++++++++++ .../src/components/TeamAnalyticsDashboard.jsx | 29 ++++-- .../TeamAnalyticsDashboard.smoke.test.jsx | 98 +++++++++++++++++++ 3 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 roadmap-planner/frontend/src/components/TeamAnalytics.smoke.test.jsx create mode 100644 roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.smoke.test.jsx diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.smoke.test.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.smoke.test.jsx new file mode 100644 index 00000000..18fd2c3b --- /dev/null +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.smoke.test.jsx @@ -0,0 +1,98 @@ +/* Mount TeamAnalytics end-to-end with realistic API responses to catch the + * dashboard crash the user reported on dev. */ +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +const allWeeks = Array.from({ length: 27 }, (_, i) => { + const d = new Date('2025-11-03'); + d.setUTCDate(d.getUTCDate() + i * 7); + return `${d.toISOString().slice(0, 10)}T00:00:00Z`; +}); +const PILLARS = ['CI/CD', 'Essentials', 'AI-Augmented SDLC', 'Tool Deployment']; + +const teamMembers = Array.from({ length: 20 }, (_, mi) => ({ + member_id: `m${mi}`, + pillars: [PILLARS[mi % PILLARS.length]], + week_totals: allWeeks.map((w, wi) => ({ + week_start: w, + prs_merged: ((mi + wi) % 5), + prs_opened: ((mi + wi + 1) % 6), + reviews: ((mi * 3 + wi) % 4), + jira_done: ((mi + wi * 2) % 7), + points: ((mi + wi) % 4) + 0.5, + })), +})); +const memberDir = teamMembers.map((t) => ({ + id: t.member_id, display_name: t.member_id, email: `${t.member_id}@e`, github_login: t.member_id, gitlab_username: t.member_id, + pillar_id: '', active: true, +})); + +vi.mock('../services/api', () => ({ + contributionsAPI: { + listMembers: vi.fn(() => Promise.resolve({ members: memberDir })), + team: vi.fn(() => Promise.resolve({ members: teamMembers })), + status: vi.fn(() => Promise.resolve({ jira: {}, github: {}, gitlab: {} })), + network: vi.fn(() => Promise.resolve({})), + pillars: vi.fn(() => Promise.resolve({ buckets: [], order: PILLARS })), + member: vi.fn(() => Promise.resolve({ info: {}, week_totals: teamMembers[0].week_totals, components_touched: [], sprint: null })), + updateMember: vi.fn(), + }, + roadmapAPI: { getBasicData: vi.fn(() => Promise.resolve({ pillars: PILLARS.map((n, i) => ({ name: n, sequence: i })) })) }, + handleAPIError: (e) => ({ message: String(e) }), +})); +vi.mock('react-hot-toast', () => ({ + default: { error: vi.fn(), success: vi.fn() }, + Toaster: () => null, +})); +import TeamAnalytics from './TeamAnalytics'; + +describe('TeamAnalytics dashboard tab', () => { + it('renders the Dashboard tab as the default with realistic data', async () => { + let utils; + await act(async () => { + utils = render(); + }); + expect(utils.container.querySelector('.ta-kpis')).not.toBeNull(); + }); + + it('survives clicking each KPI tile (changes active metric)', async () => { + let utils; + await act(async () => { + utils = render(); + }); + const kpis = utils.container.querySelectorAll('.ta-kpi--btn'); + expect(kpis.length).toBe(5); + for (const k of kpis) { + await act(async () => { fireEvent.click(k); }); + } + }); + + it('survives clicking each window seg + each pillar pill', async () => { + let utils; + await act(async () => { + utils = render(); + }); + const segs = utils.container.querySelectorAll('.ta-seg button'); + for (const s of segs) { + await act(async () => { fireEvent.click(s); }); + } + const pills = utils.container.querySelectorAll('.ta-pill'); + for (const p of pills) { + await act(async () => { fireEvent.click(p); }); + } + }); + + it('survives clicking a ranking row to drill into a member', async () => { + let utils; + await act(async () => { + utils = render(); + }); + const rankBtns = utils.container.querySelectorAll('.ta-bar-row--btn'); + if (rankBtns.length > 0) { + await act(async () => { fireEvent.click(rankBtns[0]); }); + // Should have switched to member view; member kpis should render. + expect(utils.container.querySelector('.ta-prof-name')).not.toBeNull(); + } + }); +}); diff --git a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx index fc198335..15ded8fa 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx @@ -65,9 +65,12 @@ const metricOf = (bucket, metric) => { return 0; }; +// week_start in API responses is a full ISO timestamp ("2025-11-03T00:00:00Z"); +// the dashboard's `weeks` array uses 10-char dates ("2025-11-03"). Compare on +// the date prefix so overlapping data lines up. const memberSum = (member, weeks, metric) => weeks.reduce((acc, w) => { - const b = (member.week_totals || []).find((x) => x.week_start === w); + const b = (member.week_totals || []).find((x) => (x.week_start || '').slice(0, 10) === w); return acc + metricOf(b, metric); }, 0); @@ -365,12 +368,18 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick pillarKeys.forEach((p) => { stack[p] = weeks.map(() => 0); }); filteredMembers.forEach((m) => { const p = pillarOf(m); + // A member's primary pillar may be one not present in pillarKeys + // (e.g. orderedPillarNames came back empty before basic data loaded + // — falling back to "Unassigned" mapping isn't right either, since + // the data should still render once the labels arrive). Lazily seed + // a row so we never index into undefined. + if (!stack[p]) stack[p] = weeks.map(() => 0); weeks.forEach((wk, i) => { - const b = (m.week_totals || []).find((x) => x.week_start === wk); + const b = (m.week_totals || []).find((x) => (x.week_start || '').slice(0, 10) === wk); stack[p][i] += metricOf(b, metric); }); }); - const used = pillarKeys.filter((p) => stack[p].some((v) => v > 0)); + const used = Object.keys(stack).filter((p) => stack[p].some((v) => v > 0)); return { stack, used }; }, [filteredMembers, weeks, metric, pillarKeys]); @@ -382,12 +391,18 @@ export default function DashboardView({ rows, orderedPillarNames, onMemberClick const donutSlices = useMemo(() => { const totals = {}; pillarKeys.forEach((p) => { totals[p] = 0; }); - filteredMembers.forEach((m) => { totals[pillarOf(m)] += memberSum(m, weeks, metric); }); - return pillarKeys - .map((p) => ({ label: p, value: totals[p], color: pillarColor[p] })) + filteredMembers.forEach((m) => { + const p = pillarOf(m); + // Same defensive seed as pillarStack — a member can carry a pillar + // we haven't pre-keyed. + if (totals[p] == null) totals[p] = 0; + totals[p] += memberSum(m, weeks, metric); + }); + return Object.keys(totals) + .map((p) => ({ label: p, value: totals[p], color: pillarColor[p] || colorForPillar(p, orderedPillarNames) })) .filter((s) => s.value > 0) .sort((a, b) => b.value - a.value); - }, [filteredMembers, weeks, metric, pillarKeys, pillarColor]); + }, [filteredMembers, weeks, metric, pillarKeys, pillarColor, orderedPillarNames]); const donutTotal = donutSlices.reduce((a, s) => a + s.value, 0); /* ---- Top movers (recent half vs prior half) ---- */ diff --git a/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.smoke.test.jsx b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.smoke.test.jsx new file mode 100644 index 00000000..85c1b62f --- /dev/null +++ b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.smoke.test.jsx @@ -0,0 +1,98 @@ +/* Smoke-mount TeamAnalyticsDashboard with realistic input. + * If it throws, the render error message goes to stderr. */ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +const MOCK_TEAM_RESPONSE = { members: [] }; +vi.mock('../services/api', () => ({ + contributionsAPI: { + team: vi.fn(() => Promise.resolve(MOCK_TEAM_RESPONSE)), + }, +})); +import DashboardView from './TeamAnalyticsDashboard'; + +const mkMember = (id, pillar, weekTotals) => ({ + id, name: id, pillar, pillars: [pillar], github: id, gitlab: id, + active: true, week_totals: weekTotals, +}); +const wk = (w, prsMerged, prsOpened, reviews, jiraDone, points) => ({ + week_start: `${w}T00:00:00Z`, + prs_merged: prsMerged, prs_opened: prsOpened, + reviews, jira_done: jiraDone, points, +}); + +describe('DashboardView smoke', () => { + it('mounts with empty rows', () => { + render( {}} />); + }); + it('mounts with one member, one bucket', () => { + const rows = [mkMember('alice', 'CI/CD', [wk('2026-05-04', 3, 2, 1, 0, 0)])]; + render( {}} />); + }); + it('mounts with member missing week_totals', () => { + const rows = [mkMember('alice', 'CI/CD', undefined)]; + render( {}} />); + }); + it('mounts and resolves API team payload that matches dev response', async () => { + const allWeeks = Array.from({ length: 27 }, (_, i) => { + const d = new Date('2025-11-03'); + d.setUTCDate(d.getUTCDate() + i * 7); + return d.toISOString().slice(0, 10); + }); + const pillars = ['CI/CD', 'Essentials', 'AI-Augmented SDLC', 'Tool Deployment']; + // Mimic backend response shape (member_id, pillars, week_totals with full ISO timestamps) + MOCK_TEAM_RESPONSE.members = Array.from({ length: 20 }, (_, mi) => ({ + member_id: `m${mi}`, + pillars: [pillars[mi % pillars.length]], + week_totals: allWeeks.map((w, wi) => ({ + week_start: `${w}T00:00:00Z`, + prs_merged: ((mi + wi) % 5), + prs_opened: ((mi + wi + 1) % 6), + reviews: ((mi * 3 + wi) % 4), + jira_done: ((mi + wi * 2) % 7), + points: ((mi + wi) % 4) + 0.5, + })), + })); + // Initial rows can be empty — the dashboard will replace from the API. + let result; + await act(async () => { + result = render( {}} />); + }); + expect(result.container.querySelector('.ta-kpis')).not.toBeNull(); + }); + + it('mounts when a member has a pillar NOT in orderedPillarNames (regression: dashboard crash 2026-05-10)', () => { + // Reproduces the prod crash: pillarKeys built from an empty + // orderedPillarNames + "Unassigned", but the team API returns members + // with concrete pillars like "AI-Augmented SDLC". Pre-fix this threw + // `Cannot read properties of undefined (reading '0')` inside the + // pillarStack useMemo. + const rows = [ + mkMember('alice', 'AI-Augmented SDLC', [wk('2026-05-04', 3, 2, 1, 0, 0)]), + mkMember('bo', 'Tool Integration', [wk('2026-05-04', 1, 0, 0, 0, 0)]), + ]; + render( {}} />); + }); + + it('mounts with realistic 20-member × 27-week dataset', () => { + const allWeeks = Array.from({ length: 27 }, (_, i) => { + const d = new Date('2025-11-03'); + d.setUTCDate(d.getUTCDate() + i * 7); + return d.toISOString().slice(0, 10); + }); + const pillars = ['CI/CD', 'Essentials', 'AI-Augmented SDLC', 'Tool Deployment']; + const rows = Array.from({ length: 20 }, (_, mi) => mkMember( + `m${mi}`, + pillars[mi % pillars.length], + allWeeks.map((w, wi) => wk(w, + ((mi + wi) % 5), + ((mi + wi + 1) % 6), + ((mi * 3 + wi) % 4), + ((mi + wi * 2) % 7), + ((mi + wi) % 4) + 0.5, + )), + )); + render( {}} />); + }); +}); From ca6c28c622574675c863bb83c362b7e911ca3390 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 06:44:02 +0000 Subject: [PATCH 3/7] fix(roadmap-planner): tighten member-profile alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header: switch from flex to a 4-column CSS grid so the [Switch member ▼] chip and [✎ Edit identity] button sit on row 1 with the name, while the long meta line wraps below spanning the full width. Pre-fix the meta dragged the right-side buttons out of vertical alignment with the title. - Drop double rules: cards inside the member-profile panel body lose their own border / box-shadow / background. The outer panel already provides the boundary; the doubled lines read as overkill. - Normalize spacing: the body becomes a flex column with a single 18px gap, matching the dash-grid's horizontal gap. KPI strip, weekly chart, and the 2×2 sub-grid all share the same vertical rhythm. Pre-fix the gap between the weekly chart and the cards below was zero while the gaps inside the sub-grid were 18px. - Tighten card-title spacing (4px to chart) and shrink switcher chip + edit button to a shared 28px height for visual parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../frontend/src/components/TeamAnalytics.css | 77 +++++++++++++++++-- .../frontend/src/components/TeamAnalytics.jsx | 24 +++--- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index 443439d8..15de71c1 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -827,16 +827,42 @@ .ta-donut-legend .row .pct { text-align: right; color: var(--fg-muted); } .ta-donut-legend .swatch { width: 10px; height: 10px; border-radius: var(--radius-sm, 2px); } -/* ---------- Member Profile header (new shape) ---------- */ +/* ---------- Member Profile header (new shape) ---------- + * Two-row CSS grid: + * col 1 = avatar (spans both rows, centered) + * col 2 = name (row 1) + meta (row 2 spans into col 4) + * col 3 = switcher chip (row 1) + * col 4 = edit-identity button (row 1) + * Pre-fix this was a flexbox with a nested name+meta div that pushed + * the long meta line under the name and dragged the right-side buttons + * out of vertical alignment. */ .ta-prof-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); background: var(--bg-sunken); - display: flex; + display: grid; + grid-template-columns: auto 1fr auto auto; + column-gap: 16px; + row-gap: 6px; align-items: center; - gap: 16px; - flex-wrap: wrap; } +.ta-prof-header > .ta-avatar { grid-column: 1; grid-row: 1 / span 2; align-self: center; } +.ta-prof-header > .ta-prof-name { grid-column: 2; grid-row: 1; min-width: 0; } +.ta-prof-header > .ta-switcher-chip { grid-column: 3; grid-row: 1; } +.ta-prof-header > .ta-prof-actions { grid-column: 4; grid-row: 1; } +.ta-prof-header > .ta-prof-meta { grid-column: 2 / -1; grid-row: 2; min-width: 0; } + +@media (max-width: 900px) { + .ta-prof-header { + grid-template-columns: auto 1fr auto; + row-gap: 8px; + } + .ta-prof-header > .ta-prof-name { grid-column: 2 / -1; } + .ta-prof-header > .ta-switcher-chip { grid-column: 2; grid-row: 3; justify-self: start; } + .ta-prof-header > .ta-prof-actions { grid-column: 3; grid-row: 3; } + .ta-prof-header > .ta-prof-meta { grid-column: 2 / -1; } +} + .ta-avatar--lg { width: 40px; height: 40px; font-size: 13px; } .ta-prof-name { font-family: var(--font-display); @@ -847,17 +873,28 @@ line-height: 1.1; color: var(--fg); } +.ta-prof-meta { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.06em; + color: var(--fg-muted); + word-break: break-word; +} .ta-prof-actions { - margin-left: auto; display: flex; gap: 8px; + align-items: center; } +/* Switcher chip + edit button now share the same height + sit on the + * same baseline. Chip keeps its pill shape to read as a dropdown + * affordance; button keeps its square corners to read as an action. */ .ta-switcher-chip { display: inline-flex; align-items: center; gap: 6px; - padding: 5px 12px; + height: 28px; + padding: 0 14px; border: 1px solid var(--border); border-radius: var(--radius-pill, 999px); background: var(--bg-elevated); @@ -865,7 +902,8 @@ font-size: 11px; color: var(--fg-subtle); cursor: pointer; - letter-spacing: 0.04em; + letter-spacing: 0.06em; + white-space: nowrap; } .ta-switcher-chip:hover { color: var(--fg); @@ -873,6 +911,31 @@ background: var(--bg-sunken); } .ta-switcher-chip .caret { font-size: 9px; color: var(--fg-muted); } +.ta-prof-actions .ta-btn { height: 28px; padding: 0 14px; } + +/* Member-profile body: a flex column with a single 18px gap so all + * children — KPI strip, weekly chart, the 2×2 sub-grid — share the + * same vertical rhythm. The dash-grid below uses the same 18px gap + * horizontally, so spacing is uniform in both axes. + * + * Inner .ta-card sheds its own border/shadow/background here — the + * outer panel is already the boundary. Pre-fix this looked like a + * card-inside-a-card with doubled rules and uneven margins. */ +.ta-prof-body { + display: flex; + flex-direction: column; + gap: 18px; +} +.ta-prof-body > .ta-kpis { margin-bottom: 0; } +.ta-prof-body .ta-card { + border: 0; + box-shadow: none; + background: transparent; + padding: 0; +} +.ta-prof-body .ta-card + .ta-card { margin-top: 0; } +.ta-prof-body .ta-card__title { margin-bottom: 4px; } +.ta-prof-body .ta-card__sub { margin-bottom: 8px; } /* ---------- Modal ---------- */ .ta-modal-overlay { diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx index 4c9edb62..3e43764f 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx @@ -907,18 +907,7 @@ function MemberView({ memberRow, onBack, onSaved, allRows, orderedPillarNames, o
{initialsOf(memberRow.name)} -
-

{pick(info, 'display_name', 'DisplayName') || memberRow.name}

-
- {pillarLabel} - {(pillarsList.length > 0 || memberRow.pillarOverride) - ? · {pillarSource} - : null} - {memberRow.github && <> · gh:@{memberRow.github}} - {memberRow.gitlab && <> · gl:@{memberRow.gitlab}} - {(memberRow.email || pick(info, 'email', 'Email')) && <> · {memberRow.email || pick(info, 'email', 'Email')}} -
-
+

{pick(info, 'display_name', 'DisplayName') || memberRow.name}

+
+ {pillarLabel} + {(pillarsList.length > 0 || memberRow.pillarOverride) + ? · {pillarSource} + : null} + {memberRow.github && <> · gh:@{memberRow.github}} + {memberRow.gitlab && <> · gl:@{memberRow.gitlab}} + {(memberRow.email || pick(info, 'email', 'Email')) && <> · {memberRow.email || pick(info, 'email', 'Email')}} +
-
+
{MEMBER_METRICS.map((m) => { const v = kpiTotals[m.key] || 0; From 7965731e061d7fb9e3f8b2d02039b725c9338fba Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 08:38:09 +0000 Subject: [PATCH 4/7] fix(roadmap-planner): member profile mirrors dashboard panel-grid layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the card-inside-a-panel structure that produced doubled borders and uneven padding. The member profile now mirrors the Dashboard tab exactly: - Standalone bordered header strip (avatar · name · switcher chip · edit-identity button · meta line wrapping below). - Edge-to-edge KPI strip (its own border + radius from .ta-kpis, sits between header and grid). - .ta-dash-grid below: weekly chart spans both columns, then the 2×2 sub-grid with Components · Rank · Pulse · Sprint. Each section is its own .ta-panel with the standard head + body, so charts have consistent padding and the borders read as one tier rather than two. - "Back to team overview" backlink removed; the tab nav above already carries that affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../frontend/src/components/TeamAnalytics.css | 48 ++-- .../frontend/src/components/TeamAnalytics.jsx | 225 ++++++++++-------- 2 files changed, 146 insertions(+), 127 deletions(-) diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index 15de71c1..b517dccc 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -827,19 +827,24 @@ .ta-donut-legend .row .pct { text-align: right; color: var(--fg-muted); } .ta-donut-legend .swatch { width: 10px; height: 10px; border-radius: var(--radius-sm, 2px); } -/* ---------- Member Profile header (new shape) ---------- +/* ---------- Member Profile header ---------- + * Standalone panel-style strip at the top of the Member Profile tab. + * Mirrors the Dashboard tab's masthead/toolbar shape so both tabs share + * a polished visual register: bordered, padded, sits above its own KPI + * strip + panel grid (no double-nested cards). + * * Two-row CSS grid: * col 1 = avatar (spans both rows, centered) * col 2 = name (row 1) + meta (row 2 spans into col 4) * col 3 = switcher chip (row 1) - * col 4 = edit-identity button (row 1) - * Pre-fix this was a flexbox with a nested name+meta div that pushed - * the long meta line under the name and dragged the right-side buttons - * out of vertical alignment. */ + * col 4 = edit-identity button (row 1) */ .ta-prof-header { - padding: 16px 20px; - border-bottom: 1px solid var(--border-soft); - background: var(--bg-sunken); + padding: 14px 18px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-default, 4px); + background: var(--bg-elevated); + box-shadow: var(--shadow-card); + margin-bottom: 18px; display: grid; grid-template-columns: auto 1fr auto auto; column-gap: 16px; @@ -913,29 +918,10 @@ .ta-switcher-chip .caret { font-size: 9px; color: var(--fg-muted); } .ta-prof-actions .ta-btn { height: 28px; padding: 0 14px; } -/* Member-profile body: a flex column with a single 18px gap so all - * children — KPI strip, weekly chart, the 2×2 sub-grid — share the - * same vertical rhythm. The dash-grid below uses the same 18px gap - * horizontally, so spacing is uniform in both axes. - * - * Inner .ta-card sheds its own border/shadow/background here — the - * outer panel is already the boundary. Pre-fix this looked like a - * card-inside-a-card with doubled rules and uneven margins. */ -.ta-prof-body { - display: flex; - flex-direction: column; - gap: 18px; -} -.ta-prof-body > .ta-kpis { margin-bottom: 0; } -.ta-prof-body .ta-card { - border: 0; - box-shadow: none; - background: transparent; - padding: 0; -} -.ta-prof-body .ta-card + .ta-card { margin-top: 0; } -.ta-prof-body .ta-card__title { margin-bottom: 4px; } -.ta-prof-body .ta-card__sub { margin-bottom: 8px; } +/* (Member Profile no longer wraps its sections in an outer .ta-panel — + * each section is now a standalone .ta-panel inside the .ta-dash-grid, + * mirroring the Dashboard tab's structure. The earlier .ta-prof-body + * helper class is therefore unused.) */ /* ---------- Modal ---------- */ .ta-modal-overlay { diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx index 3e43764f..24a14b16 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx @@ -900,123 +900,156 @@ function MemberView({ memberRow, onBack, onSaved, allRows, orderedPillarNames, o return ( <> - + {/* Member-profile shell mirrors the Dashboard tab's structure: a top + * header strip, a 5-tile KPI strip, then a panel grid. Each section + * is its own .ta-panel with the standard head + body. The "back to + * team overview" backlink was redundant with the tab nav above, so + * it's gone too. */} +
+ {initialsOf(memberRow.name)} +

{pick(info, 'display_name', 'DisplayName') || memberRow.name}

+ +
+ +
+
+ {pillarLabel} + {(pillarsList.length > 0 || memberRow.pillarOverride) + ? · {pillarSource} + : null} + {memberRow.github && <> · gh:@{memberRow.github}} + {memberRow.gitlab && <> · gl:@{memberRow.gitlab}} + {(memberRow.email || pick(info, 'email', 'Email')) && <> · {memberRow.email || pick(info, 'email', 'Email')}} +
+
-
-
- {initialsOf(memberRow.name)} -

{pick(info, 'display_name', 'DisplayName') || memberRow.name}

- -
- -
-
- {pillarLabel} - {(pillarsList.length > 0 || memberRow.pillarOverride) - ? · {pillarSource} - : null} - {memberRow.github && <> · gh:@{memberRow.github}} - {memberRow.gitlab && <> · gl:@{memberRow.gitlab}} - {(memberRow.email || pick(info, 'email', 'Email')) && <> · {memberRow.email || pick(info, 'email', 'Email')}} -
-
+
+ {MEMBER_METRICS.map((m) => { + const v = kpiTotals[m.key] || 0; + return ( + + ); + })} +
-
-
- {MEMBER_METRICS.map((m) => { - const v = kpiTotals[m.key] || 0; - return ( +
+
+
+
+
Weekly contribution · m.key === activeMetric)?.color }}>{MEMBER_METRICS.find((m) => m.key === activeMetric)?.label}
+
Last {weeks.length || 12} weeks · counts per ISO week
+
+
+ {MEMBER_METRICS.map((m) => ( - ); - })} -
- -
-

Weekly contribution - - {MEMBER_METRICS.map((m) => ( - - ))} - -

-
- Last {weeks.length || 12} weeks · counts per ISO week · - highlighted: {MEMBER_METRICS.find((m) => m.key === activeMetric)?.label} + ))} +
+
+
+
+
-
+
-
-
-

Components touched - Each row = one component · width = issues -

-
Where this member spent their work in the window
+
+
+
+
Components touched
+
Each row = one component · width = issues
+
+
+
+
{components.length === 0 ? (

No components recorded for this member yet — Jira issues need a Component value.

) : ( - components.map((c) => ( -
-
{c.component}
-
-
+
+ {components.map((c) => ( +
+
{c.component}
+
+
+
+
{c.issues}
-
{c.issues}
-
- )) + ))} +
)}
+
+
-
-

Rank vs team

-
Where this member sits on each metric
- {rankRows.map((r) => ( -
-
{r.label}
-
-
+
+
+
+
Rank vs team
+
Where this member sits on each metric
+
+
+
+
+
+ {rankRows.map((r) => ( +
+
{r.label}
+
+
+
+
#{r.rank}/{r.total}
-
#{r.rank}/{r.total}
-
- ))} + ))} +
+
+
-
-

Week-over-week pulse

-
Recent half vs prior half · per metric
- {pulseRows.map((p) => ( -
-
{p.label}
-
-
-
+
+
+
+
Week-over-week pulse
+
Recent half vs prior half · per metric
+
+
+
+
+
+ {pulseRows.map((p) => ( +
+
{p.label}
+
+
+
+
+
{p.prior}→{p.recent}
-
{p.prior}→{p.recent}
-
- ))} + ))} +
+
+
-
-

This sprint - - {sprint?.name || '—'} - -

-
Live · refreshes with the next collection cycle
+
+
+
+
This sprint
+
{sprint?.name || 'Live · refreshes with the next collection cycle'}
+
+
+
+
In progress
{sprint?.wip ?? '—'}
Done
{sprint?.done ?? '—'}
From cec69df772e9b25c00d35d50b012d922097e2800 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 09:01:09 +0000 Subject: [PATCH 5/7] fix(roadmap-planner): member-extras drops sprint+components on SQLite scan error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes for the member profile: 1. **Member profile sprint + components were always empty.** Backend logs showed `sprint pick: sql: Scan error on column index 1, name "MAX(r.captured_at)": unsupported Scan, storing driver.Value type string into type *time.Time`. SQLite's aggregate functions strip column type affinity and return TEXT, which the Go driver refuses to scan into *time.Time. Switch the (unused) lastSeen scan to sql.NullString. Also degrade the sprint pass to best-effort so components-touched is still returned when the sprint scan fails. 2. **prs_opened missing from MemberDetail response.** The handler builds resp manually and didn't pick up the field I added to MemberSummary in the previous commit, so the profile's KPI tile fell back to summing week_totals. 3. **Member profile: KPI→panel-grid gap matched header→KPI gap (18px)** so both arrows in the screenshot read the same vertically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/api/handlers/contributions.go | 1 + .../internal/contributions/service_extras.go | 20 ++++++++++++++----- .../frontend/src/components/TeamAnalytics.css | 10 +++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/roadmap-planner/backend/internal/api/handlers/contributions.go b/roadmap-planner/backend/internal/api/handlers/contributions.go index 7a12d049..df7a4e32 100644 --- a/roadmap-planner/backend/internal/api/handlers/contributions.go +++ b/roadmap-planner/backend/internal/api/handlers/contributions.go @@ -128,6 +128,7 @@ func (h *ContributionsHandler) MemberDetail(c *gin.Context) { "jira_issues_done": summary.JiraIssuesDone, "jira_points_done": summary.JiraPointsDone, "prs_merged": summary.PRsMerged, + "prs_opened": summary.PRsOpened, "prs_reviewed": summary.PRsReviewed, "review_latency_p50_hours": summary.ReviewLatencyP50, } diff --git a/roadmap-planner/backend/internal/contributions/service_extras.go b/roadmap-planner/backend/internal/contributions/service_extras.go index 59b22bc4..e773920c 100644 --- a/roadmap-planner/backend/internal/contributions/service_extras.go +++ b/roadmap-planner/backend/internal/contributions/service_extras.go @@ -436,6 +436,13 @@ func (s *Service) MemberExtras(ctx context.Context, memberID string, q storage.M // with a non-empty sprint_id for this member. That gives us the // sprint id + name; we then count WIP / Done by joining all rows // with the same sprint id. + // + // We scan MAX(r.captured_at) into a sql.NullString rather than + // sql.NullTime: SQLite's aggregate functions strip the column type + // affinity and hand the value back as a TEXT, which the Go driver + // rejects when the destination is *time.Time. We never use the + // value in Go code anyway (it only drives the SQL ORDER BY), so a + // string scan is sufficient. sprintSQL := rebindSimple(dialect, ` SELECT s.sprint_id, MAX(r.captured_at) FROM issue_snapshots s @@ -445,17 +452,20 @@ func (s *Service) MemberExtras(ctx context.Context, memberID string, q storage.M ORDER BY MAX(r.captured_at) DESC LIMIT 1`) var sprintID sql.NullString - var lastSeen sql.NullTime + var lastSeen sql.NullString err = db.QueryRowContext(ctx, sprintSQL, memberID).Scan(&sprintID, &lastSeen) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("sprint pick: %w", err) + // Best-effort: log and continue. Components-touched is still + // populated above, and the profile page is useful without the + // sprint card. Pre-fix this short-circuited the whole call, + // dropping components from the response too. + return out, nil } if sprintID.Valid && sprintID.String != "" { stats, err := s.sprintCounts(ctx, db, dialect, memberID, sprintID.String) - if err != nil { - return nil, err + if err == nil { + out.Sprint = stats } - out.Sprint = stats } return out, nil } diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index b517dccc..e9d703eb 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -921,7 +921,15 @@ /* (Member Profile no longer wraps its sections in an outer .ta-panel — * each section is now a standalone .ta-panel inside the .ta-dash-grid, * mirroring the Dashboard tab's structure. The earlier .ta-prof-body - * helper class is therefore unused.) */ + * helper class is therefore unused.) + * + * Match the gap between (KPI strip → dash-grid) to the (header → KPI + * strip) gap so both transitions read the same visually. .ta-kpis's + * default 6px margin-bottom was right for the Dashboard (where a hint + * row sits underneath) but too tight in this layout. */ +.ta-prof-header + .ta-kpis { + margin-bottom: 18px; +} /* ---------- Modal ---------- */ .ta-modal-overlay { From 44cabb59e92e0c5598f612cd53645ca85b254539 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 09:52:16 +0000 Subject: [PATCH 6/7] feat(roadmap-planner): components-touched fallback via fixVersion prefix matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEVOPS Jira issues rarely carry components, so the member-profile "Components touched" panel was empty for most members. Reuse the existing pillar-fallback chain (issue's own components → fixVersion prefix matching) to derive a component name when the column is empty: for each fixVersion, pick the longest component name from the matched pillar's configured components[] that is itself a prefix of the version (same -/v boundary rules as PillarsForVersions). E.g. fixVersion "tektoncd-operator-v4.11.0" against pillar components [tekton, tekton-cli, tektoncd-operator] → "tektoncd-operator". Extracted two reusable helpers on PillarMap: - PillarsFor(directComps, versions) — components-first chain shared by attributionByMember + PillarThroughput. - ComponentsFor(directComps, versions) — same chain, terminates on derived component names instead of pillars. Adds a forward pillarToComponents index so the longest-prefix match is O(comps_per_pillar) per version. Direct components win and pass through verbatim (case preserved); step-2 outputs are lowercased. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/contributions/pillar_mapping.go | 111 ++++++++++++++++++ .../contributions/pillar_mapping_test.go | 107 +++++++++++++++++ .../backend/internal/contributions/service.go | 8 +- .../internal/contributions/service_extras.go | 52 +++++--- 4 files changed, 256 insertions(+), 22 deletions(-) diff --git a/roadmap-planner/backend/internal/contributions/pillar_mapping.go b/roadmap-planner/backend/internal/contributions/pillar_mapping.go index acbea5c9..f57ba326 100644 --- a/roadmap-planner/backend/internal/contributions/pillar_mapping.go +++ b/roadmap-planner/backend/internal/contributions/pillar_mapping.go @@ -36,6 +36,12 @@ type PillarMap struct { repoGlobs []repoGlob // component name (lowercased) → pillars. componentToPillars map[string][]string + // pillar name → its configured component names (lowercased), in + // config declaration order. Used by ComponentsFor's longest-prefix + // match to derive a component from a fixVersion when the issue has + // no components set. Forward index of componentToPillars; built once + // at construction time. + pillarToComponents map[string][]string // fixVersion prefix/glob → list of pillars that claim it. Used as the // fallback when an issue has no components set; matched in declaration // order so duplicate-pattern entries collapse predictably. @@ -62,6 +68,7 @@ type versionPrefix struct { func NewPillarMap(cfg config.TeamAnalytics) *PillarMap { pm := &PillarMap{ componentToPillars: map[string][]string{}, + pillarToComponents: map[string][]string{}, } if len(cfg.Pillars) == 0 { return pm @@ -87,6 +94,7 @@ func NewPillarMap(cfg config.TeamAnalytics) *PillarMap { continue } pm.componentToPillars[c] = append(pm.componentToPillars[c], name) + pm.pillarToComponents[name] = append(pm.pillarToComponents[name], c) } for _, v := range mp.VersionPrefixes { v = strings.ToLower(strings.TrimSpace(v)) @@ -262,6 +270,109 @@ func (p *PillarMap) PillarsForComponents(components []string) []string { return out } +// PillarsFor runs the components→fixVersion fallback chain that the +// per-issue aggregators (PillarThroughput, attributionByMember) all +// repeat: try PillarsForComponents on the issue's own components first, +// then fall back to PillarsForVersions on its fixVersions. Returns the +// union of pillars matched in step 1 OR step 2 — never both, since +// step 2 only runs when step 1 returned nothing. Empty result means +// neither matcher hit; the caller decides whether to bucket under +// "Unassigned" or skip. +func (p *PillarMap) PillarsFor(directComps, versions []string) []string { + if p == nil { + return nil + } + if pillars := p.PillarsForComponents(directComps); len(pillars) > 0 { + return pillars + } + return p.PillarsForVersions(versions) +} + +// ComponentsFor derives a component-name set for one issue using the +// same components→fixVersion fallback chain. Step 1 returns the issue's +// own components (trimmed and de-duped, original case preserved). Step 2 +// runs only when step 1 is empty: for every fixVersion we find which +// pillar(s) claim it (via the same PillarsForVersions match rules) and +// pick the *longest* component name from each matched pillar's +// configured components[] that is itself a versionMatches() prefix of +// the fixVersion. Longest wins so e.g. components `[tekton, tekton-cli]` +// against version `tekton-cli-v1.0.0` returns `tekton-cli`, not both. +// +// Empty return means neither step produced anything — useful as a +// signal that this issue's components-touched cell should stay blank. +// +// Step-2 outputs are lowercased (matching the configured-component +// storage), step-1 outputs preserve the case Jira returned. +func (p *PillarMap) ComponentsFor(directComps, versions []string) []string { + if p == nil { + return nil + } + // Step 1: issue's own components win when present. + if out := dedupNonEmpty(directComps); len(out) > 0 { + return out + } + if len(p.versionPrefixes) == 0 || len(versions) == 0 || len(p.pillarToComponents) == 0 { + return nil + } + seen := map[string]bool{} + var out []string + for _, v := range versions { + key := strings.ToLower(strings.TrimSpace(v)) + if key == "" { + continue + } + matchedPillars := map[string]bool{} + for _, vp := range p.versionPrefixes { + if !versionMatches(vp.pattern, key) { + continue + } + for _, pl := range vp.pillars { + matchedPillars[pl] = true + } + } + for pillar := range matchedPillars { + best := "" + for _, c := range p.pillarToComponents[pillar] { + if !versionMatches(c, key) { + continue + } + if len(c) > len(best) { + best = c + } + } + if best == "" || seen[best] { + continue + } + seen[best] = true + out = append(out, best) + } + } + return out +} + +// dedupNonEmpty trims, drops empty entries, and de-dupes case-insensitively +// while preserving the first occurrence's original case. +func dedupNonEmpty(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]bool{} + var out []string + for _, s := range in { + t := strings.TrimSpace(s) + if t == "" { + continue + } + key := strings.ToLower(t) + if seen[key] { + continue + } + seen[key] = true + out = append(out, t) + } + return out +} + // compactRepoGlobs merges duplicate pattern entries so we don't double-count // when the same glob appears under multiple pillars. func compactRepoGlobs(in []repoGlob) []repoGlob { diff --git a/roadmap-planner/backend/internal/contributions/pillar_mapping_test.go b/roadmap-planner/backend/internal/contributions/pillar_mapping_test.go index c88da4a4..1d0cab30 100644 --- a/roadmap-planner/backend/internal/contributions/pillar_mapping_test.go +++ b/roadmap-planner/backend/internal/contributions/pillar_mapping_test.go @@ -178,6 +178,113 @@ func TestPillarMap_OrderIsStableAlpha(t *testing.T) { } } +func TestPillarMap_PillarsFor_ComponentsWin(t *testing.T) { + pm := contributions.NewPillarMap(config.TeamAnalytics{ + Pillars: map[string]config.PillarMapping{ + "CI/CD": {Components: []string{"Tekton"}, VersionPrefixes: []string{"tektoncd-operator"}}, + "Tool Deployment": {VersionPrefixes: []string{"harbor-ce-operator"}}, + }, + }) + // Components match → version fallback should NOT run, even if + // versions would also match a different pillar. + got := pm.PillarsFor([]string{"Tekton"}, []string{"harbor-ce-operator-v2.14.3"}) + if !equalUnordered(got, []string{"CI/CD"}) { + t.Fatalf("components-win failed: %v", got) + } +} + +func TestPillarMap_PillarsFor_VersionFallback(t *testing.T) { + pm := contributions.NewPillarMap(config.TeamAnalytics{ + Pillars: map[string]config.PillarMapping{ + "CI/CD": {Components: []string{"Tekton"}, VersionPrefixes: []string{"tektoncd-operator"}}, + }, + }) + got := pm.PillarsFor(nil, []string{"tektoncd-operator-v4.11.0"}) + if !equalUnordered(got, []string{"CI/CD"}) { + t.Fatalf("version-fallback failed: %v", got) + } + // Empty components AND empty versions → empty result. + if got := pm.PillarsFor(nil, nil); len(got) != 0 { + t.Fatalf("want empty, got %v", got) + } +} + +func TestPillarMap_ComponentsFor_DirectComponentsWin(t *testing.T) { + pm := contributions.NewPillarMap(config.TeamAnalytics{ + Pillars: map[string]config.PillarMapping{ + "CI/CD": {Components: []string{"tektoncd-operator"}, VersionPrefixes: []string{"tektoncd-operator"}}, + }, + }) + got := pm.ComponentsFor([]string{"Tekton", " ", "Tekton", "Triggers"}, []string{"tektoncd-operator-v4.11.0"}) + // Verbatim case preserved, dedup case-insensitive, empties dropped, + // and the version fallback does NOT run when direct components are + // present. + if !equalUnordered(got, []string{"Tekton", "Triggers"}) { + t.Fatalf("direct-components-win failed: %v", got) + } +} + +func TestPillarMap_ComponentsFor_LongestPrefixWins(t *testing.T) { + pm := contributions.NewPillarMap(config.TeamAnalytics{ + Pillars: map[string]config.PillarMapping{ + "CI/CD": { + Components: []string{"tekton", "tekton-cli", "tektoncd-operator"}, + VersionPrefixes: []string{"tektoncd-operator", "tekton-cli"}, + }, + }, + }) + cases := []struct { + name string + versions []string + want []string + }{ + {"longest match — full operator", []string{"tektoncd-operator-v4.11.0"}, []string{"tektoncd-operator"}}, + {"longest match — cli", []string{"tekton-cli-v1.0.0"}, []string{"tekton-cli"}}, + {"multi-version dedupes", []string{"tektoncd-operator-v4.11.0", "tektoncd-operator-v4.10.0"}, []string{"tektoncd-operator"}}, + {"no version match", []string{"unknown-v1.0.0"}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pm.ComponentsFor(nil, tc.versions) + if !equalUnordered(got, tc.want) { + t.Fatalf("ComponentsFor(nil, %v) = %v, want %v", tc.versions, got, tc.want) + } + }) + } +} + +func TestPillarMap_ComponentsFor_PillarMatchedByVersionPrefixOnly(t *testing.T) { + // Pillar matches the version (via versionPrefixes) but its + // components[] doesn't contain anything that's a prefix of the + // version. Expected: no derived component (we don't invent one, + // and we don't return the whole components[] list). + pm := contributions.NewPillarMap(config.TeamAnalytics{ + Pillars: map[string]config.PillarMapping{ + "CI/CD": { + Components: []string{"tekton"}, + VersionPrefixes: []string{"tektoncd-operator"}, + }, + }, + }) + got := pm.ComponentsFor(nil, []string{"tektoncd-operator-v4.11.0"}) + // "tekton" is NOT a versionMatches() prefix of "tektoncd-operator-v4.11.0" + // (would need "tekton-" or "tektonv" boundary). So we return nothing + // rather than guessing. + if len(got) != 0 { + t.Fatalf("want empty (no longest-prefix candidate), got %v", got) + } +} + +func TestPillarMap_ComponentsFor_Empty(t *testing.T) { + pm := contributions.NewPillarMap(config.TeamAnalytics{}) + if got := pm.ComponentsFor(nil, nil); len(got) != 0 { + t.Fatalf("want empty, got %v", got) + } + if got := pm.ComponentsFor([]string{"X"}, nil); !equalUnordered(got, []string{"X"}) { + t.Fatalf("direct comps should pass through even without config, got %v", got) + } +} + func equalUnordered(a, b []string) bool { if len(a) != len(b) { return false diff --git a/roadmap-planner/backend/internal/contributions/service.go b/roadmap-planner/backend/internal/contributions/service.go index e3309ab5..b4d90f18 100644 --- a/roadmap-planner/backend/internal/contributions/service.go +++ b/roadmap-planner/backend/internal/contributions/service.go @@ -231,15 +231,11 @@ func (s *Service) attributionByMember(ctx context.Context, q storage.MemberWeekQ mp[c]++ } } - // Same matcher chain as service_extras.go's pillar bucket - // aggregator: components first, fixVersions as the Phase 1 + // Shared chain: components-first, fixVersions as the Phase 1 // fallback. An issue contributes to every pillar it matches // (multi-pillar components / shared version_prefixes are // honored), and contributes nothing if neither matcher hits. - matched := pm.PillarsForComponents(comps) - if len(matched) == 0 { - matched = pm.PillarsForVersions(vers) - } + matched := pm.PillarsFor(comps, vers) if len(matched) == 0 { continue } diff --git a/roadmap-planner/backend/internal/contributions/service_extras.go b/roadmap-planner/backend/internal/contributions/service_extras.go index e773920c..f4bc1486 100644 --- a/roadmap-planner/backend/internal/contributions/service_extras.go +++ b/roadmap-planner/backend/internal/contributions/service_extras.go @@ -298,10 +298,7 @@ func (s *Service) PillarThroughput(ctx context.Context, q storage.MemberWeekQuer if pts.Valid { points = pts.Float64 } - pillars := pm.PillarsForComponents(comps) - if len(pillars) == 0 { - pillars = pm.PillarsForVersions(vers) - } + pillars := pm.PillarsFor(comps, vers) if len(pillars) == 0 { bump("Unassigned", week, 0, 1, points) continue @@ -380,10 +377,17 @@ func (s *Service) MemberExtras(ctx context.Context, memberID string, q storage.M // the window, decode the JSON components array, and tally. We use // only the latest snapshot per issue (ROW_NUMBER() over captured_at) // so an issue isn't counted N times across collection cycles. + // + // In DEVOPS, Jira tickets rarely carry components, so we run the + // same components→fixVersion fallback chain the pillar matcher uses + // (PillarMap.ComponentsFor): direct components win when present, + // otherwise we derive a component name per fixVersion via a + // longest-prefix match against the configured pillar's components[]. + // We pull `versions` alongside `components` for that step. compSQL := rebindSimple(dialect, ` - SELECT components + SELECT components, versions FROM ( - SELECT s.components, + SELECT s.components, s.versions, ROW_NUMBER() OVER (PARTITION BY s.issue_key ORDER BY r.captured_at DESC) AS rn FROM issue_snapshots s JOIN collection_runs r ON s.run_id = r.id @@ -397,23 +401,39 @@ func (s *Service) MemberExtras(ctx context.Context, memberID string, q storage.M return nil, fmt.Errorf("components: %w", err) } defer rows.Close() + pm := s.PillarMap() tally := map[string]int{} for rows.Next() { - var raw sql.NullString - if err := rows.Scan(&raw); err != nil { + var rawComps, rawVers sql.NullString + if err := rows.Scan(&rawComps, &rawVers); err != nil { return nil, err } - if !raw.Valid || raw.String == "" || raw.String == "null" { - continue + var comps, vers []string + if rawComps.Valid && rawComps.String != "" && rawComps.String != "null" { + if err := json.Unmarshal([]byte(rawComps.String), &comps); err != nil { + comps = nil // malformed; treat as empty and let fallback try + } } - var comps []string - if err := json.Unmarshal([]byte(raw.String), &comps); err != nil { - continue // malformed; skip + if rawVers.Valid && rawVers.String != "" && rawVers.String != "null" { + if err := json.Unmarshal([]byte(rawVers.String), &vers); err != nil { + vers = nil + } } - for _, c := range comps { - if c != "" { - tally[c]++ + derived := pm.ComponentsFor(comps, vers) + if len(derived) == 0 { + // No direct components and no fixVersion fallback hit — + // preserve old behaviour (tally direct comps verbatim) so + // the unconfigured-pillar path still works in installs that + // don't use TeamAnalytics at all. + for _, c := range comps { + if c != "" { + tally[c]++ + } } + continue + } + for _, c := range derived { + tally[c]++ } } if err := rows.Err(); err != nil { From 05434ed6cfd59f12f8337ba72229cebc69552234 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Sun, 10 May 2026 16:13:33 +0000 Subject: [PATCH 7/7] docs(roadmap-planner): CHARTS.md describing every analytics graph Reader-facing reference for every chart on the Dashboard, Member profile, Team overview, and Metrics tabs. Each entry lists the chart type, what it shows, the data source, and how to read it. Co-Authored-By: Claude Opus 4.7 (1M context) --- roadmap-planner/docs/team-analytics/CHARTS.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 roadmap-planner/docs/team-analytics/CHARTS.md diff --git a/roadmap-planner/docs/team-analytics/CHARTS.md b/roadmap-planner/docs/team-analytics/CHARTS.md new file mode 100644 index 00000000..1ee764bf --- /dev/null +++ b/roadmap-planner/docs/team-analytics/CHARTS.md @@ -0,0 +1,207 @@ +# Team Analytics — Charts Reference + +> **Audience**: anyone reading the roadmap-planner Team Analytics UI. +> Each chart below lists what it shows, where the data comes from, and how to read it. + +The Team Analytics feature has three tabs in the top nav (the **Metrics** tab is a separate, older DORA dashboard, also documented here): + +- [Dashboard](#dashboard-tab) — team-wide rollups, the entry point +- [Member profile](#member-profile) — drill-in for one person +- [Team overview](#team-overview-tab) — sortable roster + pillar throughput +- [Metrics (DORA)](#metrics-tab-dora) — release-process metrics + +--- + +## Filters that affect everything + +Two controls at the top of the **Dashboard** and **Member profile** drive every chart on those pages: + +| Filter | Values | Scope | +|---|---|---| +| **Window** | 4 / 12 / 26 weeks | All Dashboard charts; weekly trend on the Member profile | +| **Pillar** | All / each configured pillar / Unassigned | All Dashboard charts | +| **Active metric** | one of the 5 KPI tiles | Highlighted/selected metric in the trend, mix, donut, movers, and ranking charts | + +The five metrics that drive the active-metric selector everywhere: + +- **PRs merged** — PRs the member authored that landed +- **PRs opened** — PRs the member authored (regardless of outcome) +- **Reviews** — PRs the member reviewed (not authored) +- **Jira done** — Jira issues transitioned to a done state where the member is the assignee +- **Story points** — story points on those done issues + +--- + +## Dashboard tab + +Team-wide view. Top of the page shows the window + pillar filters and five KPI tiles. Clicking a KPI tile selects the active metric, which restyles every downstream chart. + +### KPI tiles (×5) + +- **Type**: KPI cards with up/down/flat delta badge +- **Shows**: total count over the selected window, plus the percentage change between the **prior half** and **recent half** of that window (e.g. for a 12-week window, weeks 1-6 vs. weeks 7-12) +- **Source**: `GET /api/contributions/team` +- **Reading it**: a green up-arrow means the team did more of this metric in the recent half than the prior half. The number on the tile is the total for the window. + +### Throughput trend + +- **Type**: multi-line chart, one line per metric, full panel width +- **Axes**: X = week (ISO week), Y = count +- **Source**: `GET /api/contributions/team`, weekly buckets +- **Reading it**: the **active metric** is drawn bold; the other four fade to ~18% opacity. Click another KPI tile or a legend chip to change which line is in focus. Useful for spotting a steady decline or a sudden burst on one metric while the others stay flat. + +### Pillar mix over time + +- **Type**: stacked area chart +- **Axes**: X = week, Y = total count of the active metric, stacked by pillar +- **Source**: `GET /api/contributions/team`, summed per pillar per week +- **Reading it**: each colored band is one pillar's contribution to the active metric that week. A band that grows over time means that pillar took over more of the team's work; a band that shrinks means the opposite. Pillars with zero contribution are not drawn. + +### Inflow vs outflow + +- **Type**: grouped bar chart, two bars per week +- **Axes**: X = week, Y = count; blue bar = PRs **opened**, red bar = PRs **merged** +- **Source**: `GET /api/contributions/team` +- **Reading it**: this chart **always shows PR inflow vs outflow**, regardless of the active metric. If blue consistently outpaces red, work-in-flight is accumulating. If red ≈ blue, the team is keeping pace with what it opens. + +### Distribution by pillar + +- **Type**: donut chart with legend table +- **Source**: `GET /api/contributions/team`, active metric summed by pillar +- **Reading it**: center label = total. Each slice is one pillar's share of the active metric. Legend table on the side lists each pillar with its raw count and percentage. + +### Top movers + +- **Type**: horizontal bar chart, top 8 by absolute delta +- **Axes**: each bar = one team member; bar length = signed delta (recent half − prior half) of the active metric +- **Source**: `GET /api/contributions/team`, member-level weekly buckets +- **Reading it**: bars to the right (positive, metric color) = picked up the pace; bars to the left (negative, muted) = slowed down. Click any bar to drill into that member's profile. + +### Per-member ranking + +- **Type**: horizontal bar chart, full panel width, sorted descending +- **Axes**: bar = total of the active metric for the member over the selected window +- **Source**: `GET /api/contributions/team` +- **Reading it**: bar color = the member's pillar (so you can see at a glance which pillars dominate the leaderboard). Click any bar to drill into that member's profile. + +--- + +## Member profile + +Per-member drill-in. Reachable by clicking a member in **Top movers**, **Per-member ranking**, or the **Team overview** table. Layout mirrors the Dashboard's panel grid so the brain doesn't have to re-orient. + +### Header + +Avatar (initials), name, "Switch member" dropdown, "Edit identity" button. Below: the member's pillar(s) (with a tag indicating whether the pillar was **derived** automatically or set as a manual **override**), GitHub login, GitLab username, email. + +### KPI tiles (×5) + +Same five metrics as the Dashboard, but scoped to this member. Clicking a tile selects the active metric for the panels below. Source: `GET /api/contributions/member/{id}`. + +### Weekly contribution + +- **Type**: multi-line chart, one line per metric +- **Axes**: X = week (`w1` … `w12`), Y = count +- **Source**: `detail.week_totals` from `/api/contributions/member/{id}` +- **Reading it**: same mechanic as the Dashboard's Throughput trend — the active metric is bold, others fade. Vertices are dotted on the active line so you can pin specific weeks. + +### Components touched + +- **Type**: horizontal bar chart, one bar per Jira component +- **Axes**: bar length = number of issues touched in that component +- **Source**: `detail.components_touched` +- **Reading it**: where this person actually shipped work. Bars are normalized to the maximum value, so the relative widths reflect *concentration*, not absolute volume — read the trailing count for the absolute number. +- **Note**: when a member's issues only carry a `fixVersion` like `acp-tekton-1.18.x` and no explicit component, the backend infers the component from the version prefix (Phase 1 fallback — see `PILLAR_FALLBACK_PLAN.md`). + +### Rank vs team + +- **Type**: horizontal bar chart, one bar per metric +- **Axes**: bar length = the member's count divided by the team max for that metric; right-side badge = rank (e.g. `#3/12`) +- **Source**: computed on the client from all members' `week_totals` +- **Reading it**: at a glance, where this person sits on each of the five metrics across the whole roster. A member can be top-of-roster on one metric and bottom on another — that's the point of showing all five. + +### Week-over-week pulse + +- **Type**: grouped bar chart, two bars per metric +- **Axes**: left bar (gray) = prior half of the window, right bar (metric color) = recent half +- **Source**: `detail.week_totals`, split in half +- **Reading it**: a per-metric snapshot of the same prior-vs-recent comparison the KPI tiles use, but laid out side-by-side so you can compare all five at once. + +### This sprint + +- **Type**: 2×2 KPI grid +- **Cells**: In progress, Done, PRs open, PRs merged +- **Source**: `detail.sprint` +- **Reading it**: live state for the *current* sprint (whatever the latest open sprint is on the configured Jira board). Refreshes on the next collection cycle (typically a few minutes). + +--- + +## Team overview tab + +The roster view. No window filter — fixed at last 12 weeks. + +### All members table + +- **Type**: sortable data table, one row per member +- **Columns**: Member (avatar + display name + gh/gl logins), Pillar (tag list), Jira done (count + delta), Story pts, PRs merged (count + delta), Reviews (count + delta), Review p50 (hours to first review), PRs/wk (12-week sparkline) +- **Source**: `/api/contributions/listMembers` joined with `/api/contributions/team` +- **Reading it**: click any column header to sort. Click a row to drill into the member profile. The sparkline is *not* sortable — it's a quick visual to spot streaks and lulls without leaving the table. + +### Throughput, by pillar + +- **Type**: stacked bar chart +- **Axes**: X = week (12-week fixed), Y = PRs merged, stacked by pillar +- **Source**: `GET /api/contributions/pillars` +- **Reading it**: this is the same shape as the Dashboard's Pillar mix, but always pinned to PRs merged and 12 weeks. Useful for the "who shipped what last quarter" question. + +### Review network density + +- **Type**: prose KPI panel +- **Headline metrics**: % of PRs that received their first review within 24h; % of reviews that crossed pillar boundaries +- **Bullets**: p50 / p90 review latency, orphan rate (PRs that never got a review) +- **Source**: `GET /api/contributions/network` +- **Reading it**: a pulse on the team's review culture. Low first-review-under-24h means PRs sit. High cross-pillar % means people read code outside their own area — usually a good sign for knowledge-sharing. + +--- + +## Metrics tab (DORA) + +This is the older DORA-metrics dashboard, kept alongside the Team Analytics tabs because it answers a different question: how is our **release process** behaving, not how individuals are contributing. + +Top filter: **Component** multi-select (drawn from the roadmap data, with a Clear filters button). + +### Status badge + meta + +Health indicator (Healthy / Initializing / Stale / Disabled), last-updated timestamp, and counts (releases, epics, issues considered). + +### Metric cards (×5) + +One expandable card per DORA-style metric: + +| Metric | What it measures | +|---|---| +| **Release frequency** | how often a product releases ship | +| **Lead time to release** | issue creation → release that contains it | +| **Cycle time** | issue moves to in-progress → moves to done | +| **Patch ratio** | proportion of releases that are patch-level | +| **Time to patch** | how quickly a hotfix lands after a base release | + +Click a card to expand a `MetricBreakdown` panel underneath with the per-component / per-release breakdown. + +For the full definitions and calculation logic, see `METRICS.md` in the repo root. + +--- + +## Where the data comes from (quick map) + +| Endpoint | Used by | +|---|---| +| `GET /api/contributions/team` | Dashboard KPI tiles, Throughput trend, Pillar mix, Inflow vs outflow, Donut, Top movers, Per-member ranking | +| `GET /api/contributions/member/{id}` | Member profile (header, KPIs, Weekly contribution, Components touched, Week-over-week pulse, This sprint) | +| `GET /api/contributions/listMembers` | Team overview table | +| `GET /api/contributions/pillars` | Throughput by pillar (Team overview) | +| `GET /api/contributions/network` | Review network density (Team overview) | +| `GET /api/contributions/status` | Sync indicator (header) | +| `GET /api/metrics` | Metrics tab (DORA) | + +For backend internals, see `API.md`. For the metrics calculation rules, see `METRICS.md`.