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/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/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 49588b0f..b4d90f18 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 } @@ -227,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 59b22bc4..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 { @@ -436,6 +456,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 +472,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/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/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`. diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index 8f407dd3..e9d703eb 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -691,4 +691,334 @@ .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 ---------- + * 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) */ +.ta-prof-header { + 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; + row-gap: 6px; + align-items: center; +} +.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); + 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-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 { + 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; + height: 28px; + padding: 0 14px; + 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.06em; + white-space: nowrap; +} +.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); } +.ta-prof-actions .ta-btn { height: 28px; padding: 0 14px; } + +/* (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.) + * + * 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 { + 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..24a14b16 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,379 @@ 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 ( <> - - -
- + {/* 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')}} +
+
-
-
-

Weekly contribution - - PRs merged - Jira done - Reviews - -

-
Last {weeks.length || 12} weeks · counts per ISO week
- +
+ {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) => ( + + ))} +
+
+
+
+ +
+
-
-

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}
+
+
+
+
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}
+
+
+
+
{c.issues}
+
+ ))}
- )) - )} + )} +
+
-
-

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 ?? '—'}
+
+
+
+
Rank vs team
+
Where this member sits on each metric
+
+
+
+
+
+ {rankRows.map((r) => ( +
+
{r.label}
+
+
+
+
#{r.rank}/{r.total}
+
+ ))} +
+
-
-

Identity - edit · save triggers an aggregator rebuild -

-
Map this member to a GitHub login + (optional) pillar override
-
- - - - - - +
+
+
+
Week-over-week pulse
+
Recent half vs prior half · per metric
-
- {loading && Loading…} - {saving && Saving…} - - +
+
+
+
+ {pulseRows.map((p) => ( +
+
{p.label}
+
+
+
+
+
{p.prior}→{p.recent}
+
+ ))} +
+
-
-

Switch member

-
Jump straight to another profile without going back
- +
+
+
+
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 ?? '—'}
+
+
+ + {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) => ( + + ))} +
+
+
+
+ )} + + {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 +1362,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/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 new file mode 100644 index 00000000..15ded8fa --- /dev/null +++ b/roadmap-planner/frontend/src/components/TeamAnalyticsDashboard.jsx @@ -0,0 +1,621 @@ +/* + * 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; +}; + +// 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 || '').slice(0, 10) === 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); + // 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 || '').slice(0, 10) === wk); + stack[p][i] += metricOf(b, metric); + }); + }); + const used = Object.keys(stack).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) => { + 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, orderedPillarNames]); + 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…
} + + ); +} 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( {}} />); + }); +});