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 ( - <> - -No components recorded for this member yet — Jira issues need a Component value.
- ) : ( - components.map((c) => ( -No components recorded for this member yet — Jira issues need a Component value.
+ ) : ( +No matches.
+ ) : filteredRoster.map((r) => ( + + ))} +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 ( + + ); +} + /* -------------------------------------------------------------------- */ /* 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(Click a metric tile to drive the panels below.
+ +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 ( + + ); + })} +No activity for this metric in the window.
+ ) : ranking.map((r) => { + const w = (r.val / rankingMax) * 100; + return ( + + ); + })} +