From d7883ac7da57c61e1f8be1df65ad3f6a594461ec Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:18:02 -0600 Subject: [PATCH 01/80] feat(org-lens): scaffold project detail page, route & demo data Add the Org Lens Project Detail sub-page (LFXV2-1885) shell, opened from the Overview project rows via /org/projects/:projectSlug. Includes the self-contained payload contracts, a demo-data seam (OrgLensProjectDetailService returns fixtures; an unknown slug yields the not-found state), the page-state machine, and the URL-persisted (?tab=) two-tab strip. Hero, metric cards, trend chart and leaderboard land in follow-up commits. Signed-off-by: daniel qualls --- apps/lfx-one/src/app/app.routes.ts | 11 + ...view-foundations-and-projects.component.ts | 24 +- .../org-project-detail.component.html | 97 +++++ .../org-project-detail.component.ts | 125 ++++++ .../org-lens-project-detail.demo-data.ts | 361 ++++++++++++++++++ .../org-lens-project-detail.service.ts | 30 ++ packages/shared/src/interfaces/index.ts | 3 + .../org-lens-project-detail.interface.ts | 117 ++++++ 8 files changed, 765 insertions(+), 3 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts create mode 100644 apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts create mode 100644 apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts create mode 100644 packages/shared/src/interfaces/org-lens-project-detail.interface.ts diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index 08bd67c87..84959ac08 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -113,6 +113,17 @@ export const routes: Routes = [ data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' }, loadComponent: () => import('./modules/dashboards/org/org-projects/org-projects.component').then((m) => m.OrgProjectsComponent), }, + { + path: 'projects/:projectSlug', + data: { + lens: 'org', + title: 'Project Detail', + description: "Your organization's involvement and competitive standing on a project.", + icon: 'fa-light fa-folder', + }, + loadComponent: () => + import('./modules/dashboards/org/org-project-detail/org-project-detail.component').then((m) => m.OrgProjectDetailComponent), + }, { // INFO: Future Epic implementation — the ROI page is hidden; deep links fall // back to the org overview until the org ROI feature is built. diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts index d71a14e8e..5324021b9 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts @@ -115,9 +115,27 @@ export class OrgOverviewFoundationsAndProjectsComponent { }); } - // INFO: Future Epic implementation — project-row click/keydown handlers and their - // `mfp_project_row_click` telemetry were removed with the hidden /org/projects drilldown. - // Restore them alongside the row's interactive affordances when the drilldown is built. + protected onProjectClick(payload: { projectId: string; projectName: string }): void { + const orgId = this.accountContextService.selectedAccount().accountId; + this.plausibleService.trackEvent('mfp_project_row_click', { + orgId, + projectId: payload.projectId, + projectName: payload.projectName, + }); + } + + protected onProjectRowClick(project: OrgLensFoundationRow['projects'][number]): void { + if (!project.isLfProject) return; + this.onProjectClick({ projectId: project.projectId, projectName: project.projectName }); + void this.router.navigate(['/org/projects', project.projectSlug]); + } + + protected onProjectRowKeydown(event: KeyboardEvent, project: OrgLensFoundationRow['projects'][number]): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (!project.isLfProject) return; + event.preventDefault(); + this.onProjectRowClick(project); + } private emitOverviewViewOnce(orgUid: string): void { if (this.viewedOrgs.has(orgUid)) return; diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html new file mode 100644 index 000000000..92aadbcfd --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -0,0 +1,97 @@ + + + +
+ @switch (pageState()) { + @case ('loading') { +
+
+
+
+
+
+ } + + @case ('error') { + + + } + + @case ('notFound') { + + + } + + @default { + @if (detail(); as data) { + + + + Back to Projects + + +

{{ data.hero.projectName }}

+ + +
+ @for (tab of tabs; track tab.id) { + + } +
+ + + @switch (activeTab()) { + @case ('pd-influence') { +
+

Our Influence metrics will appear here.

+
+ } + @case ('pd-leaderboards') { +
+

The project leaderboard will appear here.

+
+ } + } + } + } + } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts new file mode 100644 index 000000000..c4b8c4f11 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -0,0 +1,125 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Component, computed, inject, signal, type Signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { AccountContextService } from '@services/account-context.service'; +import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.service'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; +import type { OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; +import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; + +const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; +const VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); + +/** + * Org Lens · Project Detail sub-page (LFXV2-1885). Opened from the Projects table / + * Influence Summary cards via `/org/projects/:projectSlug`. Owns the fetch keyed on the + * selected org + slug, the page-state machine, and the URL-persisted tab strip. + */ +@Component({ + selector: 'lfx-org-project-detail', + imports: [RouterLink, EmptyStateComponent], + templateUrl: './org-project-detail.component.html', +}) +export class OrgProjectDetailComponent { + protected readonly accountContext = inject(AccountContextService); + private readonly detailService = inject(OrgLensProjectDetailService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly retryTrigger = signal(0); + protected readonly fetchLoading = signal(true); + protected readonly fetchError = signal(false); + protected readonly detail = signal(null); + + protected readonly tabs: { id: OrgLensProjectDetailTab; label: string; icon: string }[] = [ + { id: 'pd-influence', label: 'Our Influence', icon: 'fa-light fa-chart-network' }, + { id: 'pd-leaderboards', label: 'Leaderboards', icon: 'fa-light fa-ranking-star' }, + ]; + + private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap }); + + protected readonly activeTab: Signal = computed(() => this.initActiveTab()); + protected readonly pageState: Signal = computed(() => this.initPageState()); + + // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. + protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); + + protected switchTab(tab: OrgLensProjectDetailTab): void { + if (this.activeTab() === tab) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { tab: tab === DEFAULT_TAB ? null : tab }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected onTabKeydown(event: KeyboardEvent): void { + const ids = this.tabs.map((t) => t.id); + const idx = ids.indexOf(this.activeTab()); + let next: number | null = null; + if (event.key === 'ArrowRight') next = (idx + 1) % ids.length; + else if (event.key === 'ArrowLeft') next = (idx - 1 + ids.length) % ids.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = ids.length - 1; + if (next !== null) { + event.preventDefault(); + this.switchTab(ids[next]); + if (typeof document !== 'undefined') { + (document.getElementById(`project-detail-tab-trigger-${ids[next]}`) as HTMLElement | null)?.focus(); + } + } + } + + protected retry(): void { + this.retryTrigger.update((v) => v + 1); + } + + private initActiveTab(): OrgLensProjectDetailTab { + const raw = this.queryParamMap().get('tab'); + return raw && VALID_TABS.has(raw) ? (raw as OrgLensProjectDetailTab) : DEFAULT_TAB; + } + + private initDetailStream(): Observable { + const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid)); + const projectSlug$ = this.route.paramMap.pipe(map((params) => params.get('projectSlug'))); + const retryTrigger$ = toObservable(this.retryTrigger); + + return combineLatest([ + orgUid$.pipe(filter((id): id is string => !!id)), + projectSlug$.pipe(filter((slug): slug is string => !!slug)), + retryTrigger$, + ]).pipe( + tap(() => { + this.fetchLoading.set(true); + this.fetchError.set(false); + }), + switchMap(([orgUid, projectSlug]) => { + const orgName = this.accountContext.selectedAccount()?.accountName ?? ''; + return this.detailService.getProjectDetail(orgUid, orgName, projectSlug).pipe( + catchError(() => { + this.fetchError.set(true); + this.fetchLoading.set(false); + return of(null); + }) + ); + }), + tap((response) => { + this.detail.set(response); + if (!this.fetchError()) this.fetchLoading.set(false); + }) + ); + } + + private initPageState(): OrgLensProjectDetailPageState { + if (this.fetchLoading()) return 'loading'; + if (this.fetchError()) return 'error'; + if (!this.detail()) return 'notFound'; + return 'ready'; + } +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts new file mode 100644 index 000000000..89f8835f8 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -0,0 +1,361 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import type { + OrgLensProjectBand, + OrgLensProjectDetailResponse, + OrgLensProjectEcosystemCard, + OrgLensProjectHealth, + OrgLensProjectLeaderboardRow, + OrgLensProjectTechnicalCard, + OrgLensProjectTrendPoint, +} from '@lfx-one/shared/interfaces'; + +/** + * Demo company data for the Org Lens · Project Detail sub-page (LFXV2-1885). + * + * Real company-data integration (Snowflake / LFX Insights) is a separate story; until then + * `OrgLensProjectDetailService` serves these fixtures. An unknown slug returns `null` so the + * page can exercise its not-found (404) state. `jenkins` is intentionally archived (zero org + * involvement) to exercise the empty-card copy. + */ + +interface ProjectDetailSeed { + name: string; + description: string; + foundationLabel: string; + health: OrgLensProjectHealth; + sourceUrl: string; + firstCommit: string; + softwareValueUsd: number; + /** The org's own counts (rolling 365d). */ + org: { maintainers: number; contributors: number; commits: number; prs: number }; + /** Project-wide totals (rolling 365d) used as the `% of all` denominators. */ + totals: { maintainers: number; contributors: number; commits: number; prs: number }; + ecosystem: { collaboration: number; meetingAttendance: number; boardMembers: number; committeeMembers: number }; + /** The org's current influence scores; drive the trend chart endpoint + the org's leaderboard rank. */ + influence: { combined: number; technical: number; ecosystem: number }; +} + +const SEEDS: Record = { + kubernetes: { + name: 'Kubernetes', + description: 'Production-grade container orchestration.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'excellent', + sourceUrl: 'https://github.com/kubernetes/kubernetes', + firstCommit: '2014-06-06', + softwareValueUsd: 1284000000, + org: { maintainers: 5, contributors: 6, commits: 1840, prs: 624 }, + totals: { maintainers: 78, contributors: 612, commits: 32940, prs: 11280 }, + ecosystem: { collaboration: 1472, meetingAttendance: 7, boardMembers: 1, committeeMembers: 4 }, + influence: { combined: 70.1, technical: 71.5, ecosystem: 64.0 }, + }, + prometheus: { + name: 'Prometheus', + description: 'The monitoring system and time-series database.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'excellent', + sourceUrl: 'https://github.com/prometheus/prometheus', + firstCommit: '2012-11-24', + softwareValueUsd: 214000000, + org: { maintainers: 2, contributors: 3, commits: 612, prs: 208 }, + totals: { maintainers: 26, contributors: 198, commits: 7180, prs: 2410 }, + ecosystem: { collaboration: 388, meetingAttendance: 3, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 61.4, technical: 64.2, ecosystem: 52.8 }, + }, + envoy: { + name: 'Envoy', + description: 'Cloud-native high-performance edge/middle/service proxy.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'healthy', + sourceUrl: 'https://github.com/envoyproxy/envoy', + firstCommit: '2016-08-30', + softwareValueUsd: 178000000, + org: { maintainers: 3, contributors: 2, commits: 503, prs: 171 }, + totals: { maintainers: 21, contributors: 156, commits: 6020, prs: 2010 }, + ecosystem: { collaboration: 296, meetingAttendance: 2, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 58.0, technical: 62.1, ecosystem: 47.4 }, + }, + opentelemetry: { + name: 'OpenTelemetry', + description: 'High-quality, ubiquitous, portable telemetry.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'excellent', + sourceUrl: 'https://github.com/open-telemetry', + firstCommit: '2019-05-07', + softwareValueUsd: 142000000, + org: { maintainers: 2, contributors: 4, commits: 588, prs: 196 }, + totals: { maintainers: 24, contributors: 230, commits: 8120, prs: 2720 }, + ecosystem: { collaboration: 412, meetingAttendance: 4, boardMembers: 0, committeeMembers: 2 }, + influence: { combined: 63.2, technical: 60.4, ecosystem: 68.1 }, + }, + argo: { + name: 'Argo', + description: 'Kubernetes-native workflows, events, CD and rollouts.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'healthy', + sourceUrl: 'https://github.com/argoproj', + firstCommit: '2017-08-21', + softwareValueUsd: 96400000, + org: { maintainers: 1, contributors: 2, commits: 412, prs: 138 }, + totals: { maintainers: 18, contributors: 184, commits: 4920, prs: 1640 }, + ecosystem: { collaboration: 318, meetingAttendance: 3, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 55.6, technical: 58.3, ecosystem: 46.2 }, + }, + pytorch: { + name: 'PyTorch', + description: 'Tensors and dynamic neural networks with strong GPU acceleration.', + foundationLabel: 'LF AI & Data', + health: 'excellent', + sourceUrl: 'https://github.com/pytorch/pytorch', + firstCommit: '2016-08-13', + softwareValueUsd: 642000000, + org: { maintainers: 3, contributors: 4, commits: 980, prs: 332 }, + totals: { maintainers: 34, contributors: 308, commits: 14200, prs: 4760 }, + ecosystem: { collaboration: 706, meetingAttendance: 5, boardMembers: 1, committeeMembers: 2 }, + influence: { combined: 66.0, technical: 68.2, ecosystem: 60.5 }, + }, + onnx: { + name: 'ONNX', + description: 'Open standard for machine-learning interoperability.', + foundationLabel: 'LF AI & Data', + health: 'healthy', + sourceUrl: 'https://github.com/onnx/onnx', + firstCommit: '2017-09-07', + softwareValueUsd: 58200000, + org: { maintainers: 1, contributors: 2, commits: 214, prs: 72 }, + totals: { maintainers: 16, contributors: 132, commits: 3420, prs: 1140 }, + ecosystem: { collaboration: 168, meetingAttendance: 2, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 47.8, technical: 51.0, ecosystem: 41.3 }, + }, + onap: { + name: 'ONAP', + description: 'Open Network Automation Platform.', + foundationLabel: 'LF Networking', + health: 'at-risk', + sourceUrl: 'https://github.com/onap', + firstCommit: '2017-02-15', + softwareValueUsd: 39400000, + org: { maintainers: 1, contributors: 1, commits: 118, prs: 40 }, + totals: { maintainers: 14, contributors: 96, commits: 2310, prs: 770 }, + ecosystem: { collaboration: 84, meetingAttendance: 1, boardMembers: 0, committeeMembers: 0 }, + influence: { combined: 38.2, technical: 40.1, ecosystem: 34.6 }, + }, + 'fd-io': { + name: 'FD.io', + description: 'Fast data-plane I/O for the network stack.', + foundationLabel: 'LF Networking', + health: 'at-risk', + sourceUrl: 'https://github.com/FDio', + firstCommit: '2016-02-11', + softwareValueUsd: 21800000, + org: { maintainers: 0, contributors: 1, commits: 62, prs: 21 }, + totals: { maintainers: 11, contributors: 74, commits: 1480, prs: 494 }, + ecosystem: { collaboration: 41, meetingAttendance: 0, boardMembers: 0, committeeMembers: 0 }, + influence: { combined: 33.9, technical: 36.4, ecosystem: 29.0 }, + }, + sigstore: { + name: 'Sigstore', + description: 'A new standard for signing, verifying and protecting software.', + foundationLabel: 'OpenSSF', + health: 'healthy', + sourceUrl: 'https://github.com/sigstore', + firstCommit: '2020-12-08', + softwareValueUsd: 47600000, + org: { maintainers: 2, contributors: 2, commits: 318, prs: 106 }, + totals: { maintainers: 17, contributors: 128, commits: 4510, prs: 1500 }, + ecosystem: { collaboration: 214, meetingAttendance: 2, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 56.0, technical: 58.4, ecosystem: 50.1 }, + }, + 'in-toto': { + name: 'in-toto', + description: 'A framework to secure the integrity of software supply chains.', + foundationLabel: 'OpenSSF', + health: 'healthy', + sourceUrl: 'https://github.com/in-toto', + firstCommit: '2017-06-12', + softwareValueUsd: 18900000, + org: { maintainers: 1, contributors: 1, commits: 142, prs: 48 }, + totals: { maintainers: 12, contributors: 84, commits: 2210, prs: 736 }, + ecosystem: { collaboration: 76, meetingAttendance: 1, boardMembers: 0, committeeMembers: 0 }, + influence: { combined: 44.3, technical: 46.8, ecosystem: 38.0 }, + }, + tekton: { + name: 'Tekton', + description: 'Cloud-native CI/CD building blocks.', + foundationLabel: 'CD Foundation', + health: 'healthy', + sourceUrl: 'https://github.com/tektoncd', + firstCommit: '2018-08-10', + softwareValueUsd: 34200000, + org: { maintainers: 1, contributors: 2, commits: 256, prs: 86 }, + totals: { maintainers: 15, contributors: 112, commits: 3990, prs: 1330 }, + ecosystem: { collaboration: 142, meetingAttendance: 2, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 51.0, technical: 54.2, ecosystem: 42.6 }, + }, + jenkins: { + name: 'Jenkins', + description: 'The leading open-source automation server.', + foundationLabel: 'CD Foundation', + health: 'at-risk', + sourceUrl: 'https://github.com/jenkinsci', + firstCommit: '2011-02-02', + softwareValueUsd: 12400000, + // Archived in our workspace — the org has no current maintainers/contributors here (empty-card demo). + org: { maintainers: 0, contributors: 0, commits: 0, prs: 0 }, + totals: { maintainers: 19, contributors: 88, commits: 2040, prs: 680 }, + ecosystem: { collaboration: 0, meetingAttendance: 0, boardMembers: 0, committeeMembers: 0 }, + influence: { combined: 0, technical: 0, ecosystem: 0 }, + }, + 'spiffe-spire': { + name: 'SPIFFE / SPIRE', + description: 'A universal identity control plane for distributed systems.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'healthy', + sourceUrl: 'https://github.com/spiffe', + firstCommit: '2018-01-26', + softwareValueUsd: 41100000, + org: { maintainers: 2, contributors: 3, commits: 372, prs: 124 }, + totals: { maintainers: 16, contributors: 120, commits: 5230, prs: 1740 }, + ecosystem: { collaboration: 198, meetingAttendance: 2, boardMembers: 0, committeeMembers: 1 }, + influence: { combined: 60.0, technical: 62.3, ecosystem: 54.8 }, + }, +}; + +/** Twelve year-month bins ending at the current month (oldest → newest), e.g. "2025-07". */ +function trailing12Months(): string[] { + const out: string[] = []; + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); + } + return out; +} + +/** Deterministic 12-point ramp ending at `end` (starts ~`startFactor` of it). No RNG → SSR-stable. */ +function ramp(end: number, startFactor: number, round = 0): number[] { + if (end === 0) return Array.from({ length: 12 }, () => 0); + const start = end * startFactor; + const step = (end - start) / 11; + const factor = 10 ** round; + return Array.from({ length: 12 }, (_, i) => Math.round((start + step * i) * factor) / factor); +} + +function bandForScore(score: number): OrgLensProjectBand { + if (score >= 80) return 'leading'; + if (score >= 55) return 'contributing'; + if (score >= 35) return 'participating'; + return 'non-lf'; +} + +function pctOf(n: number, total: number): number { + return total === 0 ? 0 : Math.round((n / total) * 1000) / 1000; +} + +function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] { + return [ + { key: 'maintainers', label: 'Maintainers', orgCount: seed.org.maintainers, projectTotal: seed.totals.maintainers, pct: pctOf(seed.org.maintainers, seed.totals.maintainers), sparkline: ramp(seed.org.maintainers, 0.6), dataUpdatedHoursAgo: 9 }, + { key: 'contributors', label: 'Contributors', orgCount: seed.org.contributors, projectTotal: seed.totals.contributors, pct: pctOf(seed.org.contributors, seed.totals.contributors), sparkline: ramp(seed.org.contributors, 0.55), dataUpdatedHoursAgo: 1 }, + { key: 'commits', label: 'Commit Activities', orgCount: seed.org.commits, projectTotal: seed.totals.commits, pct: pctOf(seed.org.commits, seed.totals.commits), sparkline: ramp(seed.org.commits, 0.65), dataUpdatedHoursAgo: 1 }, + { key: 'pull-requests', label: 'Pull Requests', orgCount: seed.org.prs, projectTotal: seed.totals.prs, pct: pctOf(seed.org.prs, seed.totals.prs), sparkline: ramp(seed.org.prs, 0.65), dataUpdatedHoursAgo: 1 }, + ]; +} + +function ecosystemCards(seed: ProjectDetailSeed): OrgLensProjectEcosystemCard[] { + return [ + { key: 'collaboration', label: 'Collaboration Activity', count: seed.ecosystem.collaboration, dataUpdatedHoursAgo: 9 }, + { key: 'meeting-attendance', label: 'Meeting Attendance', count: seed.ecosystem.meetingAttendance, dataUpdatedHoursAgo: 2 }, + { key: 'board-members', label: 'Board Members', count: seed.ecosystem.boardMembers, dataUpdatedHoursAgo: 3 }, + { key: 'committee-members', label: 'Committee Members', count: seed.ecosystem.committeeMembers, dataUpdatedHoursAgo: 3 }, + ]; +} + +function trendSeries(seed: ProjectDetailSeed): OrgLensProjectTrendPoint[] { + const months = trailing12Months(); + const combined = ramp(seed.influence.combined, 0.83, 1); + const technical = ramp(seed.influence.technical, 0.85, 1); + const ecosystem = ramp(seed.influence.ecosystem, 0.8, 1); + return months.map((month, i) => ({ month, combined: combined[i], technical: technical[i], ecosystem: ecosystem[i] })); +} + +/** Competitor pool with relative strength (0..1); scaled per-project and merged with the viewing org. */ +const COMPETITORS: { name: string; strength: number }[] = [ + { name: 'Google', strength: 1.0 }, + { name: 'Red Hat', strength: 0.92 }, + { name: 'Microsoft', strength: 0.86 }, + { name: 'Amazon', strength: 0.79 }, + { name: 'VMware', strength: 0.72 }, + { name: 'IBM', strength: 0.65 }, + { name: 'Intel', strength: 0.58 }, + { name: 'SUSE', strength: 0.5 }, + { name: 'Huawei', strength: 0.43 }, + { name: 'Independent contributors', strength: 0.32 }, +]; + +function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLeaderboardRow[] { + // Top score scales with the project's prominence; the viewing org sits at its own combined score. + const topScore = Math.min(95, Math.max(58, seed.influence.combined * 1.32)); + const competitors = COMPETITORS.map((c) => ({ + orgName: c.name, + orgLogoUrl: '', + score: Math.round(c.strength * topScore * 10) / 10, + isViewingOrg: false, + })); + const viewing = { orgName: orgName || 'Your organization', orgLogoUrl: '', score: seed.influence.combined, isViewingOrg: true }; + + // Sort score desc, then assign rank + derive sparkline/delta/band/activity from the score. + const sorted = [...competitors, viewing].sort((a, b) => b.score - a.score); + return sorted.map((r, i) => { + const sparkline = ramp(r.score, 0.86, 1); + const start = sparkline[0]; + const delta = start === 0 ? 0 : Math.round(((r.score - start) / start) * 100) / 100; + return { + rank: i + 1, + orgName: r.orgName, + orgLogoUrl: r.orgLogoUrl, + score: Math.round(r.score * 10) / 10, + activityCount: Math.round(r.score * 46), + trendSparkline: sparkline, + trendDeltaPct: delta, + band: bandForScore(r.score), + isViewingOrg: r.isViewingOrg, + }; + }); +} + +/** + * Demo detail for one project. Returns `null` for an unknown slug so the page renders its + * not-found (404) state. `orgUid` echoes into the response envelope; `orgName` personalizes + * the viewing-org leaderboard row. + */ +export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlug: string): OrgLensProjectDetailResponse | null { + const seed = SEEDS[projectSlug]; + if (!seed) return null; + + return { + accountId: orgUid, + projectSlug, + hero: { + projectName: seed.name, + description: seed.description, + logoUrl: '', + sourceUrl: seed.sourceUrl, + sourceLabel: `${seed.name} - ${seed.description.replace(/\.$/, '')}`, + lfxInsightsUrl: `https://insights.linuxfoundation.org/project/${projectSlug}`, + firstCommit: seed.firstCommit, + softwareValueUsd: seed.softwareValueUsd, + health: seed.health, + foundationLabel: seed.foundationLabel, + // Static demo build timestamp (~1h ago) so the freshness label renders a stable relative value. + lastUpdated: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }, + technical: technicalCards(seed), + ecosystem: ecosystemCards(seed), + trend: trendSeries(seed), + leaderboard: leaderboard(seed, orgName), + }; +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts new file mode 100644 index 000000000..d4bd8b1a0 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts @@ -0,0 +1,30 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Injectable } from '@angular/core'; +import type { OrgLensProjectDetailResponse } from '@lfx-one/shared/interfaces'; +import { delay, Observable, of } from 'rxjs'; + +import { getDemoProjectDetail } from './org-lens-project-detail.demo-data'; + +/** Simulated network latency so the page exercises its loading skeletons. */ +const DEMO_LATENCY_MS = 450; + +/** + * Data seam for the Org Lens · Project Detail sub-page (LFXV2-1885). + * + * Currently returns demo company fixtures (`null` for an unknown slug → 404 state). Wiring + * the real Snowflake / LFX Insights backend (a separate story) only replaces this method + * body with an `HttpClient` call to `/api/orgs/:orgUid/lens/projects/:projectSlug` — the + * response shape and every consumer stay the same. + */ +@Injectable({ + providedIn: 'root', +}) +export class OrgLensProjectDetailService { + public getProjectDetail(orgUid: string, orgName: string, projectSlug: string): Observable { + return of(getDemoProjectDetail(orgUid, orgName, projectSlug)).pipe(delay(DEMO_LATENCY_MS)); + } +} diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 590f36557..ec5890ac9 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -81,6 +81,9 @@ export * from './org-lens.interface'; // Org Lens Projects page (influence + health) interfaces export * from './org-lens-projects.interface'; +// Org Lens · Project Detail sub-page (LFXV2-1885) interfaces +export * from './org-lens-project-detail.interface'; + // Mailing list interfaces export * from './mailing-list.interface'; diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts new file mode 100644 index 000000000..762e1ca8a --- /dev/null +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -0,0 +1,117 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Org Lens · Project Detail sub-page (LFXV2-1885) payload contracts. + * + * This story is served from demo company fixtures through `OrgLensProjectDetailService`; + * the live Snowflake / LFX Insights integration (a separate story) will populate the same + * shapes without any component changes. The contracts are self-contained so the page can + * ship independently of the sibling Projects page (LFXV2-1883 / LFXV2-1884). + */ + +/** CHAOSS-derived project health classification — drives the hero badge color token. */ +export type OrgLensProjectHealth = 'excellent' | 'healthy' | 'at-risk'; + +/** Leaderboard band per the markup-mu methodology (Boysel et al.). Strongest → weakest. */ +export type OrgLensProjectBand = 'leading' | 'contributing' | 'participating' | 'non-lf'; + +/** Leaderboard score-type segmented control (`?score=`). */ +export type OrgLensScoreType = 'combined' | 'technical' | 'ecosystem'; + +/** Leaderboard metric toggle (`?metric=`). Activity Count mode hides the Trend + Band columns. */ +export type OrgLensLeaderboardMetric = 'influence' | 'activity'; + +/** Tab strip ids (URL `?tab=`). */ +export type OrgLensProjectDetailTab = 'pd-influence' | 'pd-leaderboards'; + +/** Left/right hero block — project identity + derived stat tiles. */ +export interface OrgLensProjectHero { + projectName: string; + description: string; + /** Project logo URL; empty string falls back to initials. */ + logoUrl: string; + /** External source link target (e.g. the project's GitHub / homepage). */ + sourceUrl: string | null; + /** Human label for the source link (e.g. "Kubernetes - Production-grade container orchestration"). */ + sourceLabel: string | null; + lfxInsightsUrl: string | null; + /** Project's earliest commit ever (any author), ISO date. */ + firstCommit: string | null; + /** Project-level CHAOSS / Insights software-value estimate in USD (not the org's individual return). */ + softwareValueUsd: number | null; + health: OrgLensProjectHealth; + foundationLabel: string; + /** Last-updated timestamp, ISO. */ + lastUpdated: string; +} + +/** + * Technical-group card (Maintainers / Contributors / Commit Activities / Pull Requests). + * `% of all` = orgCount / projectTotal over a rolling 365-day window. + */ +export interface OrgLensProjectTechnicalCard { + key: 'maintainers' | 'contributors' | 'commits' | 'pull-requests'; + label: string; + orgCount: number; + projectTotal: number; + /** 0..1 share of the project total attributable to org-affiliated authors. */ + pct: number; + /** 12 monthly bins, oldest → newest. */ + sparkline: number[]; + dataUpdatedHoursAgo: number; +} + +/** + * Ecosystem-group card (Collaboration Activity / Meeting Attendance / Board Members / + * Committee Members). A single count; board/committee are point-in-time seats held. + */ +export interface OrgLensProjectEcosystemCard { + key: 'collaboration' | 'meeting-attendance' | 'board-members' | 'committee-members'; + label: string; + count: number; + dataUpdatedHoursAgo: number; +} + +/** One monthly point on the Influence Trend chart. */ +export interface OrgLensProjectTrendPoint { + /** Year-month bin, e.g. "2025-07". */ + month: string; + combined: number; + technical: number; + ecosystem: number; +} + +/** One ranked organization row on the project leaderboard. */ +export interface OrgLensProjectLeaderboardRow { + rank: number; + orgName: string; + /** Org logo URL; empty string falls back to initials. */ + orgLogoUrl: string; + /** Calculated Influence score (markup-mu, 1 decimal). */ + score: number; + /** Raw activity count for Activity Count mode (whole number). */ + activityCount: number; + /** 12-month trend sparkline, oldest → newest. */ + trendSparkline: number[]; + /** 1-year delta as a signed fraction (e.g. 0.12 = +12%). */ + trendDeltaPct: number; + band: OrgLensProjectBand; + /** The viewing org's own row — always rendered and visually pinned. */ + isViewingOrg: boolean; +} + +/** Full Project Detail payload. `accountId` + `projectSlug` echo the request envelope. */ +export interface OrgLensProjectDetailResponse { + accountId: string; + projectSlug: string; + hero: OrgLensProjectHero; + technical: OrgLensProjectTechnicalCard[]; + ecosystem: OrgLensProjectEcosystemCard[]; + trend: OrgLensProjectTrendPoint[]; + /** Sorted score-desc (tie-break: 1y delta desc → org name asc). Viewing-org row always included. */ + leaderboard: OrgLensProjectLeaderboardRow[]; +} + +/** Page-level lifecycle state for the Project Detail component. */ +export type OrgLensProjectDetailPageState = 'loading' | 'error' | 'notFound' | 'ready'; From 1dacc10ff09cefb82d6c07e723940a44f1819547 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:23:11 -0600 Subject: [PATCH 02/80] feat(org-lens): add project detail breadcrumb and hero Render the breadcrumb (Projects / / ) and the two-column hero: logo (initials fallback), name, description, Source + View on LFX Insights links, and the stat tiles (first commit, software value with the project-vs-org distinction note, health badge, foundation pill, last updated). Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 77 ++++++++++++++++--- .../org-project-detail.component.ts | 61 ++++++++++++++- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 92aadbcfd..709a00745 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -37,17 +37,74 @@ } @default { - @if (detail(); as data) { - - - - Back to Projects - + @if (hero(); as h) { + + + -

{{ data.hero.projectName }}

+ +
+ +
+ @if (h.logoUrl) { + + } @else { + + } +
+

{{ h.projectName }}

+

{{ h.description }}

+
+ @if (h.sourceUrl) { + + + {{ h.sourceLabel || 'Source' }} + + } + @if (h.lfxInsightsUrl) { + + + View on LFX Insights + + } +
+
+
+ + +
+
+ First commit + {{ firstCommitLabel() }} +
+
+ + Software value + + + {{ softwareValueLabel() }} +
+
+ Health + @if (healthMeta(); as hm) { + {{ hm.label }} + } +
+
+ Foundation + {{ h.foundationLabel }} +
+
+ Last updated + {{ lastUpdatedLabel() }} +
+
+
= new Set(['pd-influence', 'pd-leaderboards']); +/** Hero health badge label + Tailwind token classes (green Excellent / amber Healthy / red At Risk). */ +const HEALTH_META: Record = { + excellent: { label: 'Excellent', classes: 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20' }, + healthy: { label: 'Healthy', classes: 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20' }, + 'at-risk': { label: 'At Risk', classes: 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20' }, +}; + /** * Org Lens · Project Detail sub-page (LFXV2-1885). Opened from the Projects table / * Influence Summary cards via `/org/projects/:projectSlug`. Owns the fetch keyed on the @@ -22,7 +32,7 @@ const VALID_TABS: ReadonlySet = new Set(['pd-in */ @Component({ selector: 'lfx-org-project-detail', - imports: [RouterLink, EmptyStateComponent], + imports: [BreadcrumbComponent, EmptyStateComponent], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -46,6 +56,18 @@ export class OrgProjectDetailComponent { protected readonly activeTab: Signal = computed(() => this.initActiveTab()); protected readonly pageState: Signal = computed(() => this.initPageState()); + // Hero presentation — derived from the loaded payload. + protected readonly hero = computed(() => this.detail()?.hero ?? null); + protected readonly breadcrumbItems = computed(() => this.initBreadcrumb()); + protected readonly healthMeta = computed(() => { + const health = this.hero()?.health; + return health ? HEALTH_META[health] : null; + }); + protected readonly firstCommitLabel = computed(() => this.formatMonthYear(this.hero()?.firstCommit ?? null)); + protected readonly softwareValueLabel = computed(() => this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); + protected readonly lastUpdatedLabel = computed(() => this.formatRelative(this.hero()?.lastUpdated ?? null)); + protected readonly logoInitials = computed(() => this.initialsFor(this.hero()?.projectName ?? '')); + // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -122,4 +144,37 @@ export class OrgProjectDetailComponent { if (!this.detail()) return 'notFound'; return 'ready'; } + + private initBreadcrumb(): MenuItem[] { + const hero = this.hero(); + if (!hero) return [{ label: 'Projects', routerLink: ['/org/projects'] }]; + return [{ label: 'Projects', routerLink: ['/org/projects'] }, { label: hero.foundationLabel }, { label: hero.projectName }]; + } + + private formatMonthYear(dateString: string | null): string { + if (!dateString) return '—'; + try { + return parseLocalDateString(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); + } catch { + return dateString; + } + } + + private formatRelative(isoString: string | null): string { + if (!isoString) return '—'; + const parsed = new Date(isoString); + return Number.isNaN(parsed.getTime()) ? '—' : formatRelativeTime(parsed); + } + + private formatCompactUsd(value: number | null): string { + if (value === null || value === undefined) return '—'; + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); + } + + private initialsFor(name: string): string { + const parts = name.split(/[\s/]+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); + } } From c34092ef48b1c714bfb22b68068fa69e7cfaca28 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:26:50 -0600 Subject: [PATCH 03/80] feat(org-lens): add our influence technical and ecosystem cards Render the Our Influence tab card groups: four Technical cards (Maintainers, Contributors, Commit Activities, Pull Requests) via lfx-metric-card with the org count, '% of all' subtitle, 12-month sparkline and freshness label; and four Ecosystem count cards (Collaboration, Meeting Attendance, Board Members, Committee Members) with per-metric empty guidance copy. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 47 ++++++- .../org-project-detail.component.ts | 119 +++++++++++++++++- 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 709a00745..a34988727 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -138,8 +138,51 @@

-

Our Influence metrics will appear here.

+
+ +
+

Technical Influence

+
+ @for (card of technicalCards(); track card.key) { + + } +
+ @if (sourceUrl()) { + + + Get involved with this project + + } +
+ + +
+

Ecosystem Influence

+
+ @for (card of ecosystemCards(); track card.key) { +
+
+ + {{ card.title }} +
+ {{ card.count }} + @if (card.isEmpty) { + {{ card.emptyCopy }} + } + {{ card.description }} +
+ } +
+
} @case ('pd-leaderboards') { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 9d594f103..b367e7019 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -10,9 +10,19 @@ import { AccountContextService } from '@services/account-context.service'; import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.service'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; -import type { OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, OrgLensProjectDetailTab, OrgLensProjectHealth } from '@lfx-one/shared/interfaces'; -import { formatRelativeTime, parseLocalDateString } from '@lfx-one/shared/utils'; +import { MetricCardComponent } from '@components/metric-card/metric-card.component'; +import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; +import type { + OrgLensProjectDetailPageState, + OrgLensProjectDetailResponse, + OrgLensProjectDetailTab, + OrgLensProjectEcosystemCard, + OrgLensProjectHealth, + OrgLensProjectTechnicalCard, +} from '@lfx-one/shared/interfaces'; +import { formatRelativeTime, hexToRgba, parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; +import type { ChartData, ChartOptions, ChartType } from 'chart.js'; import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; @@ -25,6 +35,29 @@ const HEALTH_META: Record = { + maintainers: 'fa-light fa-user-shield', + contributors: 'fa-light fa-users', + commits: 'fa-light fa-code-commit', + 'pull-requests': 'fa-light fa-code-pull-request', +}; + +const ECOSYSTEM_ICONS: Record = { + collaboration: 'fa-light fa-comments', + 'meeting-attendance': 'fa-light fa-video', + 'board-members': 'fa-light fa-gavel', + 'committee-members': 'fa-light fa-people-group', +}; + +/** Empty-state guidance copy per Ecosystem card (shown when the count is 0). */ +const ECOSYSTEM_EMPTY_COPY: Record = { + collaboration: 'No cross-org collaboration recorded in the last year.', + 'meeting-attendance': 'No org reps attended project meetings in the last year.', + 'board-members': 'Your organization holds no board seats on this project.', + 'committee-members': 'Your organization holds no committee seats on this project.', +}; + /** * Org Lens · Project Detail sub-page (LFXV2-1885). Opened from the Projects table / * Influence Summary cards via `/org/projects/:projectSlug`. Owns the fetch keyed on the @@ -32,7 +65,7 @@ const HEALTH_META: Record this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); protected readonly lastUpdatedLabel = computed(() => this.formatRelative(this.hero()?.lastUpdated ?? null)); protected readonly logoInitials = computed(() => this.initialsFor(this.hero()?.projectName ?? '')); + protected readonly sourceUrl = computed(() => this.hero()?.sourceUrl ?? null); + + // Our Influence tab — Technical (sparkline) + Ecosystem (count) card view-models. + private readonly monthLabels: string[] = this.buildMonthLabels(); + protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toTechnicalCard(card))); + protected readonly ecosystemCards = computed(() => (this.detail()?.ecosystem ?? []).map((card) => this.toEcosystemCard(card))); // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -177,4 +216,78 @@ export class OrgProjectDetailComponent { if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } + + /** Twelve trailing short-month labels (oldest → newest) for sparkline + trend tooltips. */ + private buildMonthLabels(): string[] { + const out: string[] = []; + const now = new Date(); + for (let i = 11; i >= 0; i--) { + out.push(new Date(now.getFullYear(), now.getMonth() - i, 1).toLocaleDateString('en-US', { month: 'short' })); + } + return out; + } + + private toTechnicalCard(card: OrgLensProjectTechnicalCard) { + const pctLabel = `${(card.pct * 100).toFixed(1)}%`; + const isEmpty = card.orgCount === 0; + return { + key: card.key, + title: card.label, + icon: TECHNICAL_ICONS[card.key], + value: card.orgCount.toLocaleString(), + subtitle: `of ${card.projectTotal.toLocaleString()} (${pctLabel})`, + description: `Updated ${card.dataUpdatedHoursAgo}h ago`, + isEmpty, + chartData: this.sparklineData(card.label, card.sparkline), + chartOptions: this.sparklineOptions(card.label), + testId: `project-detail-technical-card-${card.key}`, + }; + } + + private toEcosystemCard(card: OrgLensProjectEcosystemCard) { + return { + key: card.key, + title: card.label, + icon: ECOSYSTEM_ICONS[card.key], + count: card.count.toLocaleString(), + isEmpty: card.count === 0, + emptyCopy: ECOSYSTEM_EMPTY_COPY[card.key], + description: `Updated ${card.dataUpdatedHoursAgo}h ago`, + testId: `project-detail-ecosystem-card-${card.key}`, + }; + } + + private sparklineData(label: string, series: number[]): ChartData { + return { + labels: this.monthLabels, + datasets: [ + { + label, + data: series, + borderColor: lfxColors.blue[500], + backgroundColor: hexToRgba(lfxColors.blue[500], 0.1), + fill: true, + tension: 0, + borderWidth: 2, + pointRadius: 0, + }, + ], + }; + } + + private sparklineOptions(label: string): ChartOptions { + return { + ...BASE_LINE_CHART_OPTIONS, + plugins: { + ...BASE_LINE_CHART_OPTIONS.plugins, + tooltip: { + ...(BASE_LINE_CHART_OPTIONS.plugins?.tooltip ?? {}), + callbacks: { + title: (context) => context[0]?.label ?? '', + label: (context) => `${label}: ${context.parsed.y ?? 0}`, + }, + }, + }, + }; + } } From 60e67e4429f5d04d7502a1cbba977c289f8a7e69 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:28:52 -0600 Subject: [PATCH 04/80] feat(org-lens): add influence trend chart Render the combined Influence Trend line chart in the Our Influence tab with Combined / Technical / Ecosystem overlays (legend toggles each line) and an index-mode hover tooltip. Falls back to 'Not enough history yet' when fewer than three months of data are available. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 14 ++++ .../org-project-detail.component.ts | 67 ++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index a34988727..bd7045211 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -183,6 +183,20 @@

Ecosystem Influence

}

+ + +
+

Influence Trend

+
+ @if (hasTrendHistory()) { +
+ +
+ } @else { +

Not enough history yet.

+ } +
+
} @case ('pd-leaderboards') { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index b367e7019..85f74e0a2 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -9,6 +9,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AccountContextService } from '@services/account-context.service'; import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.service'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; +import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { MetricCardComponent } from '@components/metric-card/metric-card.component'; import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; @@ -65,7 +66,7 @@ const ECOSYSTEM_EMPTY_COPY: Record = */ @Component({ selector: 'lfx-org-project-detail', - imports: [BreadcrumbComponent, EmptyStateComponent, MetricCardComponent], + imports: [BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -107,6 +108,11 @@ export class OrgProjectDetailComponent { protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toTechnicalCard(card))); protected readonly ecosystemCards = computed(() => (this.detail()?.ecosystem ?? []).map((card) => this.toEcosystemCard(card))); + // Influence Trend chart — Combined / Technical / Ecosystem overlays (legend toggles each line). + protected readonly hasTrendHistory = computed(() => (this.detail()?.trend.length ?? 0) >= 3); + protected readonly trendChartData = computed>(() => this.buildTrendData()); + protected readonly trendChartOptions: ChartOptions = this.buildTrendOptions(); + // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -290,4 +296,63 @@ export class OrgProjectDetailComponent { }, }; } + + private buildTrendData(): ChartData { + const trend = this.detail()?.trend ?? []; + const labels = trend.map((point) => this.formatTrendMonth(point.month)); + const line = (label: string, data: number[], color: string) => ({ + label, + data, + borderColor: color, + backgroundColor: color, + tension: 0.3, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + fill: false, + }); + return { + labels, + datasets: [ + line( + 'Combined', + trend.map((p) => p.combined), + lfxColors.blue[500] + ), + line( + 'Technical', + trend.map((p) => p.technical), + lfxColors.emerald[500] + ), + line( + 'Ecosystem', + trend.map((p) => p.ecosystem), + lfxColors.violet[500] + ), + ], + }; + } + + private buildTrendOptions(): ChartOptions { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: true, position: 'top', labels: { usePointStyle: true, boxWidth: 8 } }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: false, grace: '5%', ticks: { maxTicksLimit: 6 } }, + }, + }; + } + + /** "2025-07" → "Jul 2025" for the trend x-axis. */ + private formatTrendMonth(month: string): string { + const [year, mon] = month.split('-').map((n) => Number(n)); + if (!year || !mon) return month; + return new Date(year, mon - 1, 1).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + } } From d70ba2fde54a913bf88425826f565e61c6e53574 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:34:25 -0600 Subject: [PATCH 05/80] feat(org-lens): add leaderboards tab with toggles and pagination Render the project leaderboard ranking all contributing orgs by Rank / Org / Score / Trend (1y sparkline + delta) / Band. Score-type (Combined/Technical/ Ecosystem) and metric (Calculated Influence/Activity Count) toggles persist in ?score=/?metric=; Activity Count mode ranks by raw count and hides the Trend + Band columns. The viewing-org row is highlighted and pinned when it falls outside the visible top-N, and 'Show more' pages 10 at a time up to 100. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 120 ++++++++++++++- .../org-project-detail.component.ts | 144 +++++++++++++++++- .../org-lens-project-detail.demo-data.ts | 91 +++++------ .../org-lens-project-detail.interface.ts | 13 +- 4 files changed, 315 insertions(+), 53 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index bd7045211..c5bef8605 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -200,8 +200,90 @@

Influence Trend

} @case ('pd-leaderboards') { -
-

The project leaderboard will appear here.

+
+ +
+
+ @for (opt of scoreOptions; track opt.id) { + + } +
+
+ @for (opt of metricOptions; track opt.id) { + + } +
+
+ + @if (rankedRows().length === 0) { + + + } @else { +
+ + + + + + + @if (!isActivityMode()) { + + + } + + + + @for (row of visibleRows(); track row.orgName) { + + } + @if (pinnedViewingRow(); as pinned) { + + } + +
RankOrganization{{ isActivityMode() ? 'Activity' : 'Score' }}Trend (1y)Band
+
+ + @if (canShowMore()) { + + } + }
} } @@ -209,3 +291,37 @@

Influence Trend

} }
+ + + + + {{ row.rank }} + +
+ + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
+ + {{ row.valueLabel }} + @if (!isActivityMode()) { + + + + {{ row.deltaLabel }} + + + + {{ row.bandLabel }} + + } + +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 85f74e0a2..afe78eee6 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -3,6 +3,7 @@ // Generated with [Claude Code](https://claude.ai/code) +import { NgTemplateOutlet } from '@angular/common'; import { Component, computed, inject, signal, type Signal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @@ -14,12 +15,15 @@ import { EmptyStateComponent } from '@components/empty-state/empty-state.compone import { MetricCardComponent } from '@components/metric-card/metric-card.component'; import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; import type { + OrgLensLeaderboardMetric, + OrgLensProjectBand, OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, OrgLensProjectDetailTab, OrgLensProjectEcosystemCard, OrgLensProjectHealth, OrgLensProjectTechnicalCard, + OrgLensScoreType, } from '@lfx-one/shared/interfaces'; import { formatRelativeTime, hexToRgba, parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; @@ -29,6 +33,15 @@ import { catchError, combineLatest, filter, map, type Observable, of, switchMap, const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; const VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); +const DEFAULT_SCORE: OrgLensScoreType = 'combined'; +const VALID_SCORES: ReadonlySet = new Set(['combined', 'technical', 'ecosystem']); +const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; +const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); + +/** Leaderboard pagination: default top-N, increment, and hard cap (LFXV2-1885 §7). */ +const LEADERBOARD_PAGE_SIZE = 10; +const LEADERBOARD_MAX_ROWS = 100; + /** Hero health badge label + Tailwind token classes (green Excellent / amber Healthy / red At Risk). */ const HEALTH_META: Record = { excellent: { label: 'Excellent', classes: 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20' }, @@ -59,6 +72,33 @@ const ECOSYSTEM_EMPTY_COPY: Record = 'committee-members': 'Your organization holds no committee seats on this project.', }; +/** Leaderboard band chip label + Tailwind token classes. */ +const BAND_META: Record = { + leading: { label: 'Leading', classes: 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20' }, + contributing: { label: 'Contributing', classes: 'bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20' }, + participating: { label: 'Participating', classes: 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20' }, + 'non-lf': { label: 'Non-LF', classes: 'bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-500/20' }, +}; + +const SCORE_OPTIONS: { id: OrgLensScoreType; label: string }[] = [ + { id: 'combined', label: 'Combined' }, + { id: 'technical', label: 'Technical' }, + { id: 'ecosystem', label: 'Ecosystem' }, +]; + +const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string }[] = [ + { id: 'influence', label: 'Calculated Influence' }, + { id: 'activity', label: 'Activity Count' }, +]; + +/** Band thresholds per Boysel et al. markup-mu (Leading / Contributing / Participating / Non-LF). */ +function bandForScore(score: number): OrgLensProjectBand { + if (score >= 80) return 'leading'; + if (score >= 55) return 'contributing'; + if (score >= 35) return 'participating'; + return 'non-lf'; +} + /** * Org Lens · Project Detail sub-page (LFXV2-1885). Opened from the Projects table / * Influence Summary cards via `/org/projects/:projectSlug`. Owns the fetch keyed on the @@ -66,7 +106,7 @@ const ECOSYSTEM_EMPTY_COPY: Record = */ @Component({ selector: 'lfx-org-project-detail', - imports: [BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -113,6 +153,18 @@ export class OrgProjectDetailComponent { protected readonly trendChartData = computed>(() => this.buildTrendData()); protected readonly trendChartOptions: ChartOptions = this.buildTrendOptions(); + // Leaderboards tab — URL-persisted score-type + metric toggles, ranking, and pagination. + protected readonly scoreOptions = SCORE_OPTIONS; + protected readonly metricOptions = METRIC_OPTIONS; + protected readonly shownCount = signal(LEADERBOARD_PAGE_SIZE); + protected readonly scoreType = computed(() => this.initScoreType()); + protected readonly metric = computed(() => this.initMetric()); + protected readonly isActivityMode = computed(() => this.metric() === 'activity'); + protected readonly rankedRows = computed(() => this.buildRankedRows()); + protected readonly visibleRows = computed(() => this.rankedRows().slice(0, this.shownCount())); + protected readonly pinnedViewingRow = computed(() => this.rankedRows().find((r) => r.isViewingOrg && r.rank > this.shownCount()) ?? null); + protected readonly canShowMore = computed(() => this.shownCount() < Math.min(this.rankedRows().length, LEADERBOARD_MAX_ROWS)); + // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -147,6 +199,95 @@ export class OrgProjectDetailComponent { this.retryTrigger.update((v) => v + 1); } + protected setScore(score: OrgLensScoreType): void { + if (this.scoreType() === score) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { score: score === DEFAULT_SCORE ? null : score }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected setMetric(metric: OrgLensLeaderboardMetric): void { + if (this.metric() === metric) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { metric: metric === DEFAULT_METRIC ? null : metric }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected showMore(): void { + this.shownCount.update((n) => Math.min(n + LEADERBOARD_PAGE_SIZE, LEADERBOARD_MAX_ROWS, this.rankedRows().length)); + } + + private initScoreType(): OrgLensScoreType { + const raw = this.queryParamMap().get('score'); + return raw && VALID_SCORES.has(raw) ? (raw as OrgLensScoreType) : DEFAULT_SCORE; + } + + private initMetric(): OrgLensLeaderboardMetric { + const raw = this.queryParamMap().get('metric'); + return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; + } + + private buildRankedRows() { + const rows = this.detail()?.leaderboard ?? []; + const activity = this.isActivityMode(); + const type = this.scoreType(); + const valued = rows.map((row) => ({ row, value: activity ? row.activityCount : row.scores[type] })); + // Score desc; tie-break by 1y delta desc, then org name asc (LFXV2-1885 §5). + valued.sort((a, b) => b.value - a.value || b.row.trendDeltaPct - a.row.trendDeltaPct || a.row.orgName.localeCompare(b.row.orgName)); + return valued.map((entry, i) => { + const bandMeta = BAND_META[bandForScore(entry.row.scores[type])]; + return { + rank: i + 1, + orgName: entry.row.orgName, + orgLogoUrl: entry.row.orgLogoUrl, + initials: this.initialsFor(entry.row.orgName), + valueLabel: activity ? entry.value.toLocaleString() : entry.value.toFixed(1), + bandLabel: bandMeta.label, + bandClasses: bandMeta.classes, + deltaLabel: this.formatDelta(entry.row.trendDeltaPct), + deltaClasses: this.deltaClasses(entry.row.trendDeltaPct), + sparklinePoints: this.sparklinePoints(entry.row.trendSparkline), + isViewingOrg: entry.row.isViewingOrg, + }; + }); + } + + private formatDelta(delta: number): string { + const pct = Math.round(delta * 100); + return `${pct > 0 ? '+' : ''}${pct}%`; + } + + private deltaClasses(delta: number): string { + if (delta > 0.01) return 'text-emerald-600'; + if (delta < -0.01) return 'text-red-600'; + return 'text-gray-500'; + } + + /** Inline-SVG polyline points for a 64×20 sparkline (avoids 100 chart.js canvases). */ + private sparklinePoints(series: number[]): string { + if (series.length === 0) return ''; + const width = 64; + const height = 20; + const pad = 2; + const min = Math.min(...series); + const max = Math.max(...series); + const range = max - min || 1; + const span = series.length > 1 ? series.length - 1 : 1; + return series + .map((value, i) => { + const x = pad + (i * (width - 2 * pad)) / span; + const y = height - pad - ((value - min) / range) * (height - 2 * pad); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + } + private initActiveTab(): OrgLensProjectDetailTab { const raw = this.queryParamMap().get('tab'); return raw && VALID_TABS.has(raw) ? (raw as OrgLensProjectDetailTab) : DEFAULT_TAB; @@ -178,6 +319,7 @@ export class OrgProjectDetailComponent { }), tap((response) => { this.detail.set(response); + this.shownCount.set(LEADERBOARD_PAGE_SIZE); if (!this.fetchError()) this.fetchLoading.set(false); }) ); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 89f8835f8..7054ec8a5 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -4,7 +4,6 @@ // Generated with [Claude Code](https://claude.ai/code) import type { - OrgLensProjectBand, OrgLensProjectDetailResponse, OrgLensProjectEcosystemCard, OrgLensProjectHealth, @@ -245,13 +244,6 @@ function ramp(end: number, startFactor: number, round = 0): number[] { return Array.from({ length: 12 }, (_, i) => Math.round((start + step * i) * factor) / factor); } -function bandForScore(score: number): OrgLensProjectBand { - if (score >= 80) return 'leading'; - if (score >= 55) return 'contributing'; - if (score >= 35) return 'participating'; - return 'non-lf'; -} - function pctOf(n: number, total: number): number { return total === 0 ? 0 : Math.round((n / total) * 1000) / 1000; } @@ -282,49 +274,60 @@ function trendSeries(seed: ProjectDetailSeed): OrgLensProjectTrendPoint[] { return months.map((month, i) => ({ month, combined: combined[i], technical: technical[i], ecosystem: ecosystem[i] })); } -/** Competitor pool with relative strength (0..1); scaled per-project and merged with the viewing org. */ -const COMPETITORS: { name: string; strength: number }[] = [ - { name: 'Google', strength: 1.0 }, - { name: 'Red Hat', strength: 0.92 }, - { name: 'Microsoft', strength: 0.86 }, - { name: 'Amazon', strength: 0.79 }, - { name: 'VMware', strength: 0.72 }, - { name: 'IBM', strength: 0.65 }, - { name: 'Intel', strength: 0.58 }, - { name: 'SUSE', strength: 0.5 }, - { name: 'Huawei', strength: 0.43 }, - { name: 'Independent contributors', strength: 0.32 }, +/** + * Competitor pool with relative strength (0..1) plus per-org technical / ecosystem biases so the + * score-type toggle genuinely re-ranks (an org strong technically but weak on ecosystem moves). + */ +const COMPETITORS: { name: string; strength: number; techBias: number; ecoBias: number }[] = [ + { name: 'Google', strength: 1.0, techBias: 1.05, ecoBias: 0.92 }, + { name: 'Red Hat', strength: 0.92, techBias: 0.96, ecoBias: 1.12 }, + { name: 'Microsoft', strength: 0.86, techBias: 1.08, ecoBias: 0.9 }, + { name: 'Amazon', strength: 0.79, techBias: 1.02, ecoBias: 0.95 }, + { name: 'VMware', strength: 0.72, techBias: 0.93, ecoBias: 1.1 }, + { name: 'IBM', strength: 0.65, techBias: 0.9, ecoBias: 1.15 }, + { name: 'Intel', strength: 0.58, techBias: 1.07, ecoBias: 0.88 }, + { name: 'SUSE', strength: 0.5, techBias: 0.98, ecoBias: 1.04 }, + { name: 'Huawei', strength: 0.43, techBias: 1.04, ecoBias: 0.86 }, + { name: 'Independent contributors', strength: 0.32, techBias: 1.0, ecoBias: 0.8 }, ]; +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLeaderboardRow[] { - // Top score scales with the project's prominence; the viewing org sits at its own combined score. + // Top score scales with the project's prominence; the viewing org sits at its own influence scores. const topScore = Math.min(95, Math.max(58, seed.influence.combined * 1.32)); - const competitors = COMPETITORS.map((c) => ({ - orgName: c.name, - orgLogoUrl: '', - score: Math.round(c.strength * topScore * 10) / 10, - isViewingOrg: false, - })); - const viewing = { orgName: orgName || 'Your organization', orgLogoUrl: '', score: seed.influence.combined, isViewingOrg: true }; - - // Sort score desc, then assign rank + derive sparkline/delta/band/activity from the score. - const sorted = [...competitors, viewing].sort((a, b) => b.score - a.score); - return sorted.map((r, i) => { - const sparkline = ramp(r.score, 0.86, 1); - const start = sparkline[0]; - const delta = start === 0 ? 0 : Math.round(((r.score - start) / start) * 100) / 100; + const rows: OrgLensProjectLeaderboardRow[] = COMPETITORS.map((c) => { + const combined = round1(c.strength * topScore); return { - rank: i + 1, - orgName: r.orgName, - orgLogoUrl: r.orgLogoUrl, - score: Math.round(r.score * 10) / 10, - activityCount: Math.round(r.score * 46), - trendSparkline: sparkline, - trendDeltaPct: delta, - band: bandForScore(r.score), - isViewingOrg: r.isViewingOrg, + orgName: c.name, + orgLogoUrl: '', + scores: { combined, technical: round1(Math.min(99, combined * c.techBias)), ecosystem: round1(Math.min(99, combined * c.ecoBias)) }, + activityCount: Math.round(combined * 46), + trendSparkline: ramp(combined, 0.86, 1), + trendDeltaPct: deltaFromSparkline(ramp(combined, 0.86, 1)), + isViewingOrg: false, }; }); + + const viewingSpark = ramp(seed.influence.combined, 0.82, 1); + rows.push({ + orgName: orgName || 'Your organization', + orgLogoUrl: '', + scores: { combined: round1(seed.influence.combined), technical: round1(seed.influence.technical), ecosystem: round1(seed.influence.ecosystem) }, + activityCount: Math.round(seed.influence.combined * 46), + trendSparkline: viewingSpark, + trendDeltaPct: deltaFromSparkline(viewingSpark), + isViewingOrg: true, + }); + + return rows; +} + +function deltaFromSparkline(series: number[]): number { + const start = series[0]; + return start === 0 ? 0 : Math.round(((series[series.length - 1] - start) / start) * 100) / 100; } /** diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index 762e1ca8a..3db002c93 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -82,21 +82,22 @@ export interface OrgLensProjectTrendPoint { ecosystem: number; } -/** One ranked organization row on the project leaderboard. */ +/** + * One organization row on the project leaderboard. Rank and band are derived client-side + * from the active score-type / metric, so they are not carried on the wire. + */ export interface OrgLensProjectLeaderboardRow { - rank: number; orgName: string; /** Org logo URL; empty string falls back to initials. */ orgLogoUrl: string; - /** Calculated Influence score (markup-mu, 1 decimal). */ - score: number; + /** Calculated Influence scores (markup-mu, 1 decimal) per score-type. */ + scores: { combined: number; technical: number; ecosystem: number }; /** Raw activity count for Activity Count mode (whole number). */ activityCount: number; /** 12-month trend sparkline, oldest → newest. */ trendSparkline: number[]; /** 1-year delta as a signed fraction (e.g. 0.12 = +12%). */ trendDeltaPct: number; - band: OrgLensProjectBand; /** The viewing org's own row — always rendered and visually pinned. */ isViewingOrg: boolean; } @@ -109,7 +110,7 @@ export interface OrgLensProjectDetailResponse { technical: OrgLensProjectTechnicalCard[]; ecosystem: OrgLensProjectEcosystemCard[]; trend: OrgLensProjectTrendPoint[]; - /** Sorted score-desc (tie-break: 1y delta desc → org name asc). Viewing-org row always included. */ + /** All organizations contributing to the project; the viewing-org row is always included. */ leaderboard: OrgLensProjectLeaderboardRow[]; } From 9fdd523ec0ef474936aa051a3cb17c510e878f1c Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:37:51 -0600 Subject: [PATCH 06/80] test(org-lens): add project detail e2e coverage Add Playwright coverage for the Project Detail page: testid resolution (breadcrumb, hero, card groups, trend), tab switching via click/keyboard with ?tab= persistence, leaderboard ranking with the viewing-org pin + Show more, score/metric URL persistence, Activity Count hiding Trend + Band, and the not-found panel for an unknown slug. Also normalizes formatting. Signed-off-by: daniel qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 124 ++++++++++++++++++ apps/lfx-one/src/app/app.routes.ts | 3 +- .../org-project-detail.component.html | 67 +++++++--- .../org-project-detail.component.ts | 6 +- .../org-lens-project-detail.demo-data.ts | 40 +++++- 5 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 apps/lfx-one/e2e/org-project-detail.spec.ts diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts new file mode 100644 index 000000000..d4452e7da --- /dev/null +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -0,0 +1,124 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Org Lens · Project Detail Page E2E Tests (LFXV2-1885) + * + * Covers the acceptance criteria for the per-project Org Lens view: + * - data-testid resolution smoke test (breadcrumb, hero, tabs, card groups, trend) + * - tab strip switching (click + keyboard) with ?tab= URL persistence + * - leaderboard ranking, score/metric toggles with ?score=/?metric= persistence, + * Activity Count hiding the Trend + Band columns, and Show more pagination + * - not-found (404) panel for an unknown slug + * + * Prerequisites: + * - Dev server running on the Playwright baseURL + * - User authenticated with the `org-lens-enabled` flag on and an organization selected + * + * Demo semantics (v1): the page is served from frontend demo fixtures + * (org-lens-project-detail.demo-data.ts). `kubernetes` is a rich seeded project; an + * unknown slug returns null → the not-found panel. + */ + +import { expect, test } from '@playwright/test'; + +const DETAIL_URL = '/org/projects/kubernetes'; +const DETAIL_URL_BOGUS = '/org/projects/totally-bogus-project'; +const DATA_LOAD_TIMEOUT = 30_000; + +test.setTimeout(90_000); + +test.describe('Org Project Detail — testid resolution', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DETAIL_URL, { waitUntil: 'domcontentloaded' }); + await expect(page).not.toHaveURL(/auth0\.com/); + await expect(page.getByTestId('project-detail-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); + + test('renders breadcrumb, hero and tab strip', async ({ page }) => { + await expect(page.getByTestId('project-detail-breadcrumb')).toBeVisible(); + await expect(page.getByTestId('project-detail-hero')).toBeVisible(); + await expect(page.getByTestId('project-detail-name')).toHaveText('Kubernetes'); + await expect(page.getByTestId('project-detail-first-commit')).toBeVisible(); + await expect(page.getByTestId('project-detail-software-value')).toBeVisible(); + await expect(page.getByTestId('project-detail-health-badge')).toBeVisible(); + await expect(page.getByTestId('project-detail-foundation-pill')).toBeVisible(); + await expect(page.getByTestId('project-detail-tab-pd-influence')).toBeVisible(); + await expect(page.getByTestId('project-detail-tab-pd-leaderboards')).toBeVisible(); + }); + + test('renders the Technical and Ecosystem card groups', async ({ page }) => { + for (const key of ['maintainers', 'contributors', 'commits', 'pull-requests']) { + await expect(page.getByTestId(`project-detail-technical-card-${key}`)).toBeVisible(); + } + for (const key of ['collaboration', 'meeting-attendance', 'board-members', 'committee-members']) { + await expect(page.getByTestId(`project-detail-ecosystem-card-${key}`)).toBeVisible(); + } + await expect(page.getByTestId('project-detail-trend-group')).toBeVisible(); + }); +}); + +test.describe('Org Project Detail — tab strip', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DETAIL_URL, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('project-detail-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); + + test('defaults to Our Influence and switches to Leaderboards via click + URL', async ({ page }) => { + await expect(page.getByTestId('project-detail-technical-group')).toBeVisible(); + await page.getByTestId('project-detail-tab-pd-leaderboards').click(); + await expect(page).toHaveURL(/tab=pd-leaderboards/); + await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible(); + }); + + test('deep-links to the Leaderboards tab via ?tab=', async ({ page }) => { + await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); + + test('arrow keys move between tabs', async ({ page }) => { + await page.getByTestId('project-detail-tab-pd-influence').focus(); + await page.keyboard.press('ArrowRight'); + await expect(page.getByTestId('project-detail-tab-pd-leaderboards')).toBeFocused(); + }); +}); + +test.describe('Org Project Detail — leaderboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); + + test('pins the viewing-org row and paginates with Show more', async ({ page }) => { + await expect(page.getByTestId('project-detail-leaderboard-viewing-row')).toBeVisible(); + const showMore = page.getByTestId('project-detail-leaderboard-show-more'); + if (await showMore.isVisible()) { + const before = await page.locator('[data-testid="project-detail-leaderboard-table"] tbody tr').count(); + await showMore.click(); + const after = await page.locator('[data-testid="project-detail-leaderboard-table"] tbody tr').count(); + expect(after).toBeGreaterThan(before); + } + }); + + test('score + metric toggles persist in the URL', async ({ page }) => { + await page.getByTestId('project-detail-score-technical').click(); + await expect(page).toHaveURL(/score=technical/); + await page.getByTestId('project-detail-metric-activity').click(); + await expect(page).toHaveURL(/metric=activity/); + }); + + test('Activity Count mode hides the Trend + Band columns', async ({ page }) => { + await expect(page.getByRole('columnheader', { name: 'Trend (1y)' })).toBeVisible(); + await page.getByTestId('project-detail-metric-activity').click(); + await expect(page.getByRole('columnheader', { name: 'Trend (1y)' })).toHaveCount(0); + await expect(page.getByRole('columnheader', { name: 'Band' })).toHaveCount(0); + }); +}); + +test.describe('Org Project Detail — not found', () => { + test('renders the 404 panel for an unknown slug', async ({ page }) => { + await page.goto(DETAIL_URL_BOGUS, { waitUntil: 'domcontentloaded' }); + await expect(page).not.toHaveURL(/auth0\.com/); + await expect(page.getByTestId('project-detail-not-found')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); +}); diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index 84959ac08..6772a6af4 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -121,8 +121,7 @@ export const routes: Routes = [ description: "Your organization's involvement and competitive standing on a project.", icon: 'fa-light fa-folder', }, - loadComponent: () => - import('./modules/dashboards/org/org-project-detail/org-project-detail.component').then((m) => m.OrgProjectDetailComponent), + loadComponent: () => import('./modules/dashboards/org/org-project-detail/org-project-detail.component').then((m) => m.OrgProjectDetailComponent), }, { // INFO: Future Epic implementation — the ROI page is hidden; deep links fall diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index c5bef8605..aa37f3537 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -46,7 +46,9 @@ -
+
@if (h.logoUrl) { @@ -61,13 +63,23 @@

{{ h.description }}

@if (h.sourceUrl) { - + {{ h.sourceLabel || 'Source' }} } @if (h.lfxInsightsUrl) { - + View on LFX Insights @@ -85,19 +97,31 @@

Software value - + {{ softwareValueLabel() }}

Health @if (healthMeta(); as hm) { - {{ hm.label }} + {{ hm.label }} }
Foundation - {{ h.foundationLabel }} + {{ h.foundationLabel }}
Last updated @@ -138,7 +162,11 @@

+

Technical Influence

@@ -157,7 +185,12 @@

Technical Influence

}
@if (sourceUrl()) { - + Get involved with this project @@ -200,7 +233,11 @@

Influence Trend

} @case ('pd-leaderboards') { -
+
@@ -260,14 +297,10 @@

Influence Trend

@for (row of visibleRows(); track row.orgName) { - + } @if (pinnedViewingRow(); as pinned) { - + } @@ -302,7 +335,9 @@

Influence Trend

{{ row.rank }}
- + {{ row.orgName }} @if (row.isViewingOrg) { You diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index afe78eee6..582473891 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -298,11 +298,7 @@ export class OrgProjectDetailComponent { const projectSlug$ = this.route.paramMap.pipe(map((params) => params.get('projectSlug'))); const retryTrigger$ = toObservable(this.retryTrigger); - return combineLatest([ - orgUid$.pipe(filter((id): id is string => !!id)), - projectSlug$.pipe(filter((slug): slug is string => !!slug)), - retryTrigger$, - ]).pipe( + return combineLatest([orgUid$.pipe(filter((id): id is string => !!id)), projectSlug$.pipe(filter((slug): slug is string => !!slug)), retryTrigger$]).pipe( tap(() => { this.fetchLoading.set(true); this.fetchError.set(false); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 7054ec8a5..846b150a5 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -250,10 +250,42 @@ function pctOf(n: number, total: number): number { function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] { return [ - { key: 'maintainers', label: 'Maintainers', orgCount: seed.org.maintainers, projectTotal: seed.totals.maintainers, pct: pctOf(seed.org.maintainers, seed.totals.maintainers), sparkline: ramp(seed.org.maintainers, 0.6), dataUpdatedHoursAgo: 9 }, - { key: 'contributors', label: 'Contributors', orgCount: seed.org.contributors, projectTotal: seed.totals.contributors, pct: pctOf(seed.org.contributors, seed.totals.contributors), sparkline: ramp(seed.org.contributors, 0.55), dataUpdatedHoursAgo: 1 }, - { key: 'commits', label: 'Commit Activities', orgCount: seed.org.commits, projectTotal: seed.totals.commits, pct: pctOf(seed.org.commits, seed.totals.commits), sparkline: ramp(seed.org.commits, 0.65), dataUpdatedHoursAgo: 1 }, - { key: 'pull-requests', label: 'Pull Requests', orgCount: seed.org.prs, projectTotal: seed.totals.prs, pct: pctOf(seed.org.prs, seed.totals.prs), sparkline: ramp(seed.org.prs, 0.65), dataUpdatedHoursAgo: 1 }, + { + key: 'maintainers', + label: 'Maintainers', + orgCount: seed.org.maintainers, + projectTotal: seed.totals.maintainers, + pct: pctOf(seed.org.maintainers, seed.totals.maintainers), + sparkline: ramp(seed.org.maintainers, 0.6), + dataUpdatedHoursAgo: 9, + }, + { + key: 'contributors', + label: 'Contributors', + orgCount: seed.org.contributors, + projectTotal: seed.totals.contributors, + pct: pctOf(seed.org.contributors, seed.totals.contributors), + sparkline: ramp(seed.org.contributors, 0.55), + dataUpdatedHoursAgo: 1, + }, + { + key: 'commits', + label: 'Commit Activities', + orgCount: seed.org.commits, + projectTotal: seed.totals.commits, + pct: pctOf(seed.org.commits, seed.totals.commits), + sparkline: ramp(seed.org.commits, 0.65), + dataUpdatedHoursAgo: 1, + }, + { + key: 'pull-requests', + label: 'Pull Requests', + orgCount: seed.org.prs, + projectTotal: seed.totals.prs, + pct: pctOf(seed.org.prs, seed.totals.prs), + sparkline: ramp(seed.org.prs, 0.65), + dataUpdatedHoursAgo: 1, + }, ]; } From 5698a9f243f92a0491e81f75421347bea1c22820 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 17:01:43 -0600 Subject: [PATCH 07/80] refactor(org-lens): align project detail with design + lfx patterns Apply the wireframe's visual language through existing LFX components rather than hand-rolled markup: render the health badge and leaderboard band chips with the shared lfx-tag wrapper (severity-mapped), rename the card-group headings to 'Our Technical/Ecosystem Influence', and add the Project/Foundation scope label to each Ecosystem card. Ticket structure (trend chart, single leaderboard with toggles, viewing-org pin, Show more) is unchanged. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 26 ++++++------ .../org-project-detail.component.ts | 41 ++++++++++++------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index aa37f3537..59bc330b2 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -107,12 +107,9 @@

Health @if (healthMeta(); as hm) { - {{ hm.label }} + + + }

@@ -169,7 +166,7 @@

-

Technical Influence

+

Our Technical Influence

@for (card of technicalCards(); track card.key) { Technical Influence

-

Ecosystem Influence

+

Our Ecosystem Influence

@for (card of ecosystemCards(); track card.key) {
-
- - {{ card.title }} +
+
+ + {{ card.title }} +
+ {{ + card.scopeLabel + }}
{{ card.count }} @if (card.isEmpty) { @@ -355,7 +357,7 @@

Influence Trend

- {{ row.bandLabel }} + } diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 582473891..f5deb9fdd 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -13,6 +13,7 @@ import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { MetricCardComponent } from '@components/metric-card/metric-card.component'; +import { TagComponent } from '@components/tag/tag.component'; import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; import type { OrgLensLeaderboardMetric, @@ -24,6 +25,7 @@ import type { OrgLensProjectHealth, OrgLensProjectTechnicalCard, OrgLensScoreType, + TagSeverity, } from '@lfx-one/shared/interfaces'; import { formatRelativeTime, hexToRgba, parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; @@ -42,11 +44,11 @@ const VALID_METRICS: ReadonlySet = new Set(['i const LEADERBOARD_PAGE_SIZE = 10; const LEADERBOARD_MAX_ROWS = 100; -/** Hero health badge label + Tailwind token classes (green Excellent / amber Healthy / red At Risk). */ -const HEALTH_META: Record = { - excellent: { label: 'Excellent', classes: 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20' }, - healthy: { label: 'Healthy', classes: 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20' }, - 'at-risk': { label: 'At Risk', classes: 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20' }, +/** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ +const HEALTH_TAG: Record = { + excellent: { label: 'Excellent', severity: 'success' }, + healthy: { label: 'Healthy', severity: 'warn' }, + 'at-risk': { label: 'At Risk', severity: 'danger' }, }; /** FontAwesome icons keyed by Technical / Ecosystem card. */ @@ -72,12 +74,20 @@ const ECOSYSTEM_EMPTY_COPY: Record = 'committee-members': 'Your organization holds no committee seats on this project.', }; -/** Leaderboard band chip label + Tailwind token classes. */ -const BAND_META: Record = { - leading: { label: 'Leading', classes: 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20' }, - contributing: { label: 'Contributing', classes: 'bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20' }, - participating: { label: 'Participating', classes: 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20' }, - 'non-lf': { label: 'Non-LF', classes: 'bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-500/20' }, +/** Ecosystem card scope label (per the wireframe): project-level vs foundation-level metric. */ +const ECOSYSTEM_SCOPE: Record = { + collaboration: 'Project', + 'meeting-attendance': 'Project', + 'board-members': 'Foundation', + 'committee-members': 'Foundation', +}; + +/** Leaderboard band chip → lfx-tag severity. */ +const BAND_TAG: Record = { + leading: { label: 'Leading', severity: 'success' }, + contributing: { label: 'Contributing', severity: 'info' }, + participating: { label: 'Participating', severity: 'warn' }, + 'non-lf': { label: 'Non-LF', severity: 'secondary' }, }; const SCORE_OPTIONS: { id: OrgLensScoreType; label: string }[] = [ @@ -106,7 +116,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent, TagComponent], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -135,7 +145,7 @@ export class OrgProjectDetailComponent { protected readonly breadcrumbItems = computed(() => this.initBreadcrumb()); protected readonly healthMeta = computed(() => { const health = this.hero()?.health; - return health ? HEALTH_META[health] : null; + return health ? HEALTH_TAG[health] : null; }); protected readonly firstCommitLabel = computed(() => this.formatMonthYear(this.hero()?.firstCommit ?? null)); protected readonly softwareValueLabel = computed(() => this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); @@ -241,7 +251,7 @@ export class OrgProjectDetailComponent { // Score desc; tie-break by 1y delta desc, then org name asc (LFXV2-1885 §5). valued.sort((a, b) => b.value - a.value || b.row.trendDeltaPct - a.row.trendDeltaPct || a.row.orgName.localeCompare(b.row.orgName)); return valued.map((entry, i) => { - const bandMeta = BAND_META[bandForScore(entry.row.scores[type])]; + const bandMeta = BAND_TAG[bandForScore(entry.row.scores[type])]; return { rank: i + 1, orgName: entry.row.orgName, @@ -249,7 +259,7 @@ export class OrgProjectDetailComponent { initials: this.initialsFor(entry.row.orgName), valueLabel: activity ? entry.value.toLocaleString() : entry.value.toFixed(1), bandLabel: bandMeta.label, - bandClasses: bandMeta.classes, + bandSeverity: bandMeta.severity, deltaLabel: this.formatDelta(entry.row.trendDeltaPct), deltaClasses: this.deltaClasses(entry.row.trendDeltaPct), sparklinePoints: this.sparklinePoints(entry.row.trendSparkline), @@ -393,6 +403,7 @@ export class OrgProjectDetailComponent { key: card.key, title: card.label, icon: ECOSYSTEM_ICONS[card.key], + scopeLabel: ECOSYSTEM_SCOPE[card.key], count: card.count.toLocaleString(), isEmpty: card.count === 0, emptyCopy: ECOSYSTEM_EMPTY_COPY[card.key], From 25b108919423e8205a72fa23728804f3d3223c63 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 17:22:36 -0600 Subject: [PATCH 08/80] refactor(org-lens): two side-by-side leaderboards per prototype Restructure the Leaderboards tab to match the prototype: side-by-side Technical and Ecosystem Influence boards (the established training/events pattern), each with a Last-12-months header, per-board search, top-10 rows (# / Organization / Influence Score band chip), and the viewing-org row pinned below with a blue rank badge when outside the top 10. Replaces the single combined table; drops the Combined/Technical/Ecosystem segmented control (the two boards cover technical + ecosystem), the trend column, and Show more. The Calculated Influence / Activity Count toggle persists in ?metric= and swaps the score column. E2e updated. Signed-off-by: daniel qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 37 ++- .../org-project-detail.component.html | 222 +++++++++--------- .../org-project-detail.component.ts | 120 +++------- 3 files changed, 171 insertions(+), 208 deletions(-) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index d4452e7da..e0304543f 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -68,7 +68,7 @@ test.describe('Org Project Detail — tab strip', () => { await expect(page.getByTestId('project-detail-technical-group')).toBeVisible(); await page.getByTestId('project-detail-tab-pd-leaderboards').click(); await expect(page).toHaveURL(/tab=pd-leaderboards/); - await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible(); + await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible(); }); test('deep-links to the Leaderboards tab via ?tab=', async ({ page }) => { @@ -83,35 +83,32 @@ test.describe('Org Project Detail — tab strip', () => { }); }); -test.describe('Org Project Detail — leaderboard', () => { +test.describe('Org Project Detail — leaderboards', () => { test.beforeEach(async ({ page }) => { await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); }); - test('pins the viewing-org row and paginates with Show more', async ({ page }) => { - await expect(page.getByTestId('project-detail-leaderboard-viewing-row')).toBeVisible(); - const showMore = page.getByTestId('project-detail-leaderboard-show-more'); - if (await showMore.isVisible()) { - const before = await page.locator('[data-testid="project-detail-leaderboard-table"] tbody tr').count(); - await showMore.click(); - const after = await page.locator('[data-testid="project-detail-leaderboard-table"] tbody tr').count(); - expect(after).toBeGreaterThan(before); - } + test('renders both side-by-side boards with the viewing-org row pinned', async ({ page }) => { + await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible(); + await expect(page.getByTestId('project-detail-leaderboard-ecosystem')).toBeVisible(); + await expect(page.getByTestId('project-detail-leaderboard-technical-viewing-row')).toBeVisible(); + await expect(page.getByTestId('project-detail-leaderboard-ecosystem-viewing-row')).toBeVisible(); }); - test('score + metric toggles persist in the URL', async ({ page }) => { - await page.getByTestId('project-detail-score-technical').click(); - await expect(page).toHaveURL(/score=technical/); + test('metric toggle persists in the URL and switches the score column', async ({ page }) => { + await expect(page.getByRole('columnheader', { name: 'Influence Score' }).first()).toBeVisible(); await page.getByTestId('project-detail-metric-activity').click(); await expect(page).toHaveURL(/metric=activity/); + await expect(page.getByRole('columnheader', { name: 'Activity (12mo)' }).first()).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Influence Score' })).toHaveCount(0); }); - test('Activity Count mode hides the Trend + Band columns', async ({ page }) => { - await expect(page.getByRole('columnheader', { name: 'Trend (1y)' })).toBeVisible(); - await page.getByTestId('project-detail-metric-activity').click(); - await expect(page.getByRole('columnheader', { name: 'Trend (1y)' })).toHaveCount(0); - await expect(page.getByRole('columnheader', { name: 'Band' })).toHaveCount(0); + test('search filters a board to matching organizations', async ({ page }) => { + await page.getByTestId('project-detail-search-technical').fill('Google'); + const rows = page.locator('[data-testid="project-detail-leaderboard-technical"] tbody tr'); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText('Google'); }); }); diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 59bc330b2..35cca50df 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -240,85 +240,49 @@

Influence Trend

id="project-detail-tab-panel-pd-leaderboards" class="flex flex-col gap-4" [attr.aria-labelledby]="'project-detail-tab-trigger-pd-leaderboards'"> - -
-
- @for (opt of scoreOptions; track opt.id) { - - } -
-
- @for (opt of metricOptions; track opt.id) { - - } -
-
- - @if (rankedRows().length === 0) { - - - } @else { -
- - - - - - - @if (!isActivityMode()) { - - - } - - - - @for (row of visibleRows(); track row.orgName) { - - } - @if (pinnedViewingRow(); as pinned) { - - } - -
RankOrganization{{ isActivityMode() ? 'Activity' : 'Score' }}Trend (1y)Band
-
- - @if (canShowMore()) { + +
+ @for (opt of metricOptions; track opt.id) { } - } +
+ + +
+ + +
} } @@ -327,38 +291,82 @@

Influence Trend

}
- - - - {{ row.rank }} - -
- - {{ row.orgName }} - @if (row.isViewingOrg) { - You - } + + +
+
+
+
{{ title }}
+
Last 12 months
+
+
+ +
- - {{ row.valueLabel }} - @if (!isActivityMode()) { - - - - {{ row.deltaLabel }} - - - - - +
+ + @if (board.visible.length === 0) { +

+ {{ searchValue.trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} +

+ } @else { + + + + + + + + + + @for (row of board.visible; track row.orgName) { + + + + + + } + @if (board.pinned; as pinned) { + + + + + + } + +
#Organization{{ scoreColumnLabel() }}
{{ row.rank }} + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } + + @if (isActivityMode()) { + {{ row.activityLabel }} + } @else { + + } +
+ {{ pinned.rank }} + + {{ pinned.orgName }} + You + + @if (isActivityMode()) { + {{ pinned.activityLabel }} + } @else { + + } +
} - +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index f5deb9fdd..6fbd957e2 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -24,7 +24,6 @@ import type { OrgLensProjectEcosystemCard, OrgLensProjectHealth, OrgLensProjectTechnicalCard, - OrgLensScoreType, TagSeverity, } from '@lfx-one/shared/interfaces'; import { formatRelativeTime, hexToRgba, parseLocalDateString } from '@lfx-one/shared/utils'; @@ -35,14 +34,14 @@ import { catchError, combineLatest, filter, map, type Observable, of, switchMap, const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; const VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); -const DEFAULT_SCORE: OrgLensScoreType = 'combined'; -const VALID_SCORES: ReadonlySet = new Set(['combined', 'technical', 'ecosystem']); const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); -/** Leaderboard pagination: default top-N, increment, and hard cap (LFXV2-1885 §7). */ -const LEADERBOARD_PAGE_SIZE = 10; -const LEADERBOARD_MAX_ROWS = 100; +/** Rows shown per leaderboard before the viewing-org row is pinned below. */ +const LEADERBOARD_TOP_N = 10; + +/** Dimension keyed by each side-by-side leaderboard. */ +type LeaderboardDimension = 'technical' | 'ecosystem'; /** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ const HEALTH_TAG: Record = { @@ -90,12 +89,6 @@ const BAND_TAG: Record>(() => this.buildTrendData()); protected readonly trendChartOptions: ChartOptions = this.buildTrendOptions(); - // Leaderboards tab — URL-persisted score-type + metric toggles, ranking, and pagination. - protected readonly scoreOptions = SCORE_OPTIONS; + // Leaderboards tab — URL-persisted metric toggle + two side-by-side dimension boards with search. protected readonly metricOptions = METRIC_OPTIONS; - protected readonly shownCount = signal(LEADERBOARD_PAGE_SIZE); - protected readonly scoreType = computed(() => this.initScoreType()); protected readonly metric = computed(() => this.initMetric()); protected readonly isActivityMode = computed(() => this.metric() === 'activity'); - protected readonly rankedRows = computed(() => this.buildRankedRows()); - protected readonly visibleRows = computed(() => this.rankedRows().slice(0, this.shownCount())); - protected readonly pinnedViewingRow = computed(() => this.rankedRows().find((r) => r.isViewingOrg && r.rank > this.shownCount()) ?? null); - protected readonly canShowMore = computed(() => this.shownCount() < Math.min(this.rankedRows().length, LEADERBOARD_MAX_ROWS)); + protected readonly scoreColumnLabel = computed(() => (this.isActivityMode() ? 'Activity (12mo)' : 'Influence Score')); + protected readonly techSearch = signal(''); + protected readonly ecoSearch = signal(''); + protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); + protected readonly ecosystemBoard = computed(() => this.buildBoard('ecosystem', this.ecoSearch())); // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -209,16 +200,6 @@ export class OrgProjectDetailComponent { this.retryTrigger.update((v) => v + 1); } - protected setScore(score: OrgLensScoreType): void { - if (this.scoreType() === score) return; - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { score: score === DEFAULT_SCORE ? null : score }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - } - protected setMetric(metric: OrgLensLeaderboardMetric): void { if (this.metric() === metric) return; void this.router.navigate([], { @@ -229,13 +210,9 @@ export class OrgProjectDetailComponent { }); } - protected showMore(): void { - this.shownCount.update((n) => Math.min(n + LEADERBOARD_PAGE_SIZE, LEADERBOARD_MAX_ROWS, this.rankedRows().length)); - } - - private initScoreType(): OrgLensScoreType { - const raw = this.queryParamMap().get('score'); - return raw && VALID_SCORES.has(raw) ? (raw as OrgLensScoreType) : DEFAULT_SCORE; + protected onSearch(dimension: LeaderboardDimension, event: Event): void { + const value = (event.target as HTMLInputElement).value; + (dimension === 'technical' ? this.techSearch : this.ecoSearch).set(value); } private initMetric(): OrgLensLeaderboardMetric { @@ -243,59 +220,39 @@ export class OrgProjectDetailComponent { return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; } - private buildRankedRows() { - const rows = this.detail()?.leaderboard ?? []; - const activity = this.isActivityMode(); - const type = this.scoreType(); - const valued = rows.map((row) => ({ row, value: activity ? row.activityCount : row.scores[type] })); - // Score desc; tie-break by 1y delta desc, then org name asc (LFXV2-1885 §5). - valued.sort((a, b) => b.value - a.value || b.row.trendDeltaPct - a.row.trendDeltaPct || a.row.orgName.localeCompare(b.row.orgName)); - return valued.map((entry, i) => { - const bandMeta = BAND_TAG[bandForScore(entry.row.scores[type])]; + /** Per-dimension activity count (12mo) derived from that dimension's score, so the two boards differ. */ + private activityFor(score: number): number { + return Math.round(score * 46); + } + + /** + * Rank the leaderboard for one dimension (technical / ecosystem), then apply the search filter. + * With no search: top-N rows plus the viewing-org row pinned below when it falls outside top-N. + * With a search: all matching rows (the viewing-org row appears inline if it matches). + */ + private buildBoard(dimension: LeaderboardDimension, search: string) { + const valued = (this.detail()?.leaderboard ?? []).map((row) => ({ row, score: row.scores[dimension] })); + // Score desc; tie-break org name asc. + valued.sort((a, b) => b.score - a.score || a.row.orgName.localeCompare(b.row.orgName)); + const ranked = valued.map((entry, i) => { + const bandMeta = BAND_TAG[bandForScore(entry.score)]; return { rank: i + 1, orgName: entry.row.orgName, - orgLogoUrl: entry.row.orgLogoUrl, initials: this.initialsFor(entry.row.orgName), - valueLabel: activity ? entry.value.toLocaleString() : entry.value.toFixed(1), + activityLabel: this.activityFor(entry.score).toLocaleString(), bandLabel: bandMeta.label, bandSeverity: bandMeta.severity, - deltaLabel: this.formatDelta(entry.row.trendDeltaPct), - deltaClasses: this.deltaClasses(entry.row.trendDeltaPct), - sparklinePoints: this.sparklinePoints(entry.row.trendSparkline), isViewingOrg: entry.row.isViewingOrg, }; }); - } - - private formatDelta(delta: number): string { - const pct = Math.round(delta * 100); - return `${pct > 0 ? '+' : ''}${pct}%`; - } - - private deltaClasses(delta: number): string { - if (delta > 0.01) return 'text-emerald-600'; - if (delta < -0.01) return 'text-red-600'; - return 'text-gray-500'; - } - /** Inline-SVG polyline points for a 64×20 sparkline (avoids 100 chart.js canvases). */ - private sparklinePoints(series: number[]): string { - if (series.length === 0) return ''; - const width = 64; - const height = 20; - const pad = 2; - const min = Math.min(...series); - const max = Math.max(...series); - const range = max - min || 1; - const span = series.length > 1 ? series.length - 1 : 1; - return series - .map((value, i) => { - const x = pad + (i * (width - 2 * pad)) / span; - const y = height - pad - ((value - min) / range) * (height - 2 * pad); - return `${x.toFixed(1)},${y.toFixed(1)}`; - }) - .join(' '); + const query = search.trim().toLowerCase(); + if (query) { + return { visible: ranked.filter((r) => r.orgName.toLowerCase().includes(query)), pinned: null }; + } + const pinned = ranked.find((r) => r.isViewingOrg && r.rank > LEADERBOARD_TOP_N) ?? null; + return { visible: ranked.slice(0, LEADERBOARD_TOP_N), pinned }; } private initActiveTab(): OrgLensProjectDetailTab { @@ -325,7 +282,8 @@ export class OrgProjectDetailComponent { }), tap((response) => { this.detail.set(response); - this.shownCount.set(LEADERBOARD_PAGE_SIZE); + this.techSearch.set(''); + this.ecoSearch.set(''); if (!this.fetchError()) this.fetchLoading.set(false); }) ); From 7f7c910e502a9c6d382aa06940a341a2045de7fa Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 17:31:14 -0600 Subject: [PATCH 09/80] refactor(org-lens): unify influence cards with trendlines per prototype Make the Ecosystem cards match the Technical cards: one shared card style with a title, dual-line trendline (metric line + faint baseline), and a descriptive sentence with the stat bolded. Ecosystem cards gain a project/foundation scope label header (project name for Collaboration + Meetings; foundation name for Board + Committee members) and a 'No data' state when there's no series. Add the org's influence-level band badge beside each section heading. Drop the per-card freshness label and the hero 'Last updated' tile. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 69 +++---- .../org-project-detail.component.ts | 168 ++++++++++-------- .../org-lens-project-detail.demo-data.ts | 41 ++++- .../org-lens-project-detail.interface.ts | 9 +- 4 files changed, 165 insertions(+), 122 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 35cca50df..8c5691cbe 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -120,10 +120,6 @@

- Last updated - {{ lastUpdatedLabel() }} -

@@ -166,19 +162,15 @@

-

Our Technical Influence

+
+

Our Technical Influence

+ @if (technicalBand(); as b) { + + } +
@for (card of technicalCards(); track card.key) { - + }
@if (sourceUrl()) { @@ -196,25 +188,15 @@

Our Technical Influence

-

Our Ecosystem Influence

+
+

Our Ecosystem Influence

+ @if (ecosystemBand(); as b) { + + } +
@for (card of ecosystemCards(); track card.key) { -
-
-
- - {{ card.title }} -
- {{ - card.scopeLabel - }} -
- {{ card.count }} - @if (card.isEmpty) { - {{ card.emptyCopy }} - } - {{ card.description }} -
+ }
@@ -291,6 +273,29 @@

Influence Trend

}

+ + +
+ @if (card.scopeLabel) { +
{{ card.scopeLabel }}
+ } +
+
{{ card.title }}
+
+ @if (card.hasData) { + + } @else { +
No data
+ } +
+

+ {{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }} +

+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 6fbd957e2..e6c5779f0 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -12,7 +12,6 @@ import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.s import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; -import { MetricCardComponent } from '@components/metric-card/metric-card.component'; import { TagComponent } from '@components/tag/tag.component'; import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; import type { @@ -26,7 +25,7 @@ import type { OrgLensProjectTechnicalCard, TagSeverity, } from '@lfx-one/shared/interfaces'; -import { formatRelativeTime, hexToRgba, parseLocalDateString } from '@lfx-one/shared/utils'; +import { parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; import type { ChartData, ChartOptions, ChartType } from 'chart.js'; import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; @@ -50,35 +49,12 @@ const HEALTH_TAG: Record = { - maintainers: 'fa-light fa-user-shield', - contributors: 'fa-light fa-users', - commits: 'fa-light fa-code-commit', - 'pull-requests': 'fa-light fa-code-pull-request', -}; - -const ECOSYSTEM_ICONS: Record = { - collaboration: 'fa-light fa-comments', - 'meeting-attendance': 'fa-light fa-video', - 'board-members': 'fa-light fa-gavel', - 'committee-members': 'fa-light fa-people-group', -}; - -/** Empty-state guidance copy per Ecosystem card (shown when the count is 0). */ -const ECOSYSTEM_EMPTY_COPY: Record = { - collaboration: 'No cross-org collaboration recorded in the last year.', - 'meeting-attendance': 'No org reps attended project meetings in the last year.', - 'board-members': 'Your organization holds no board seats on this project.', - 'committee-members': 'Your organization holds no committee seats on this project.', -}; - -/** Ecosystem card scope label (per the wireframe): project-level vs foundation-level metric. */ -const ECOSYSTEM_SCOPE: Record = { - collaboration: 'Project', - 'meeting-attendance': 'Project', - 'board-members': 'Foundation', - 'committee-members': 'Foundation', +/** Ecosystem card scope (per the wireframe): collaboration + meetings are project-level; the rest foundation-level. */ +const ECOSYSTEM_SCOPE: Record = { + collaboration: 'project', + 'meeting-attendance': 'project', + 'board-members': 'foundation', + 'committee-members': 'foundation', }; /** Leaderboard band chip → lfx-tag severity. */ @@ -109,7 +85,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, MetricCardComponent, TagComponent], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -142,12 +118,23 @@ export class OrgProjectDetailComponent { }); protected readonly firstCommitLabel = computed(() => this.formatMonthYear(this.hero()?.firstCommit ?? null)); protected readonly softwareValueLabel = computed(() => this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); - protected readonly lastUpdatedLabel = computed(() => this.formatRelative(this.hero()?.lastUpdated ?? null)); protected readonly logoInitials = computed(() => this.initialsFor(this.hero()?.projectName ?? '')); protected readonly sourceUrl = computed(() => this.hero()?.sourceUrl ?? null); - // Our Influence tab — Technical (sparkline) + Ecosystem (count) card view-models. + // Org's own influence standing (from its leaderboard row) → section-title band badges. + private readonly viewingScores = computed(() => this.detail()?.leaderboard.find((row) => row.isViewingOrg)?.scores ?? null); + protected readonly technicalBand = computed(() => { + const scores = this.viewingScores(); + return scores ? BAND_TAG[bandForScore(scores.technical)] : null; + }); + protected readonly ecosystemBand = computed(() => { + const scores = this.viewingScores(); + return scores ? BAND_TAG[bandForScore(scores.ecosystem)] : null; + }); + + // Our Influence tab — Technical + Ecosystem cards (trendline + sentence), same card style. private readonly monthLabels: string[] = this.buildMonthLabels(); + protected readonly cardChartOptions: ChartOptions = { ...BASE_LINE_CHART_OPTIONS }; protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toTechnicalCard(card))); protected readonly ecosystemCards = computed(() => (this.detail()?.ecosystem ?? []).map((card) => this.toEcosystemCard(card))); @@ -311,12 +298,6 @@ export class OrgProjectDetailComponent { } } - private formatRelative(isoString: string | null): string { - if (!isoString) return '—'; - const parsed = new Date(isoString); - return Number.isNaN(parsed.getTime()) ? '—' : formatRelativeTime(parsed); - } - private formatCompactUsd(value: number | null): string { if (value === null || value === undefined) return '—'; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); @@ -340,70 +321,99 @@ export class OrgProjectDetailComponent { } private toTechnicalCard(card: OrgLensProjectTechnicalCard) { - const pctLabel = `${(card.pct * 100).toFixed(1)}%`; - const isEmpty = card.orgCount === 0; return { key: card.key, title: card.label, - icon: TECHNICAL_ICONS[card.key], - value: card.orgCount.toLocaleString(), - subtitle: `of ${card.projectTotal.toLocaleString()} (${pctLabel})`, - description: `Updated ${card.dataUpdatedHoursAgo}h ago`, - isEmpty, - chartData: this.sparklineData(card.label, card.sparkline), - chartOptions: this.sparklineOptions(card.label), + scopeLabel: null as string | null, + hasData: card.sparkline.length > 0, + chartData: this.cardChartData(card.sparkline, lfxColors.blue[500]), + caption: this.technicalCaption(card), testId: `project-detail-technical-card-${card.key}`, }; } private toEcosystemCard(card: OrgLensProjectEcosystemCard) { + const hero = this.hero(); + const projectName = hero?.projectName ?? 'this project'; + const foundationLabel = hero?.foundationLabel ?? 'the foundation'; + const scopeLabel = ECOSYSTEM_SCOPE[card.key] === 'project' ? `${projectName} Project` : foundationLabel; return { key: card.key, title: card.label, - icon: ECOSYSTEM_ICONS[card.key], - scopeLabel: ECOSYSTEM_SCOPE[card.key], - count: card.count.toLocaleString(), - isEmpty: card.count === 0, - emptyCopy: ECOSYSTEM_EMPTY_COPY[card.key], - description: `Updated ${card.dataUpdatedHoursAgo}h ago`, + scopeLabel, + hasData: card.sparkline.length > 0, + chartData: this.cardChartData(card.sparkline, lfxColors.violet[500]), + caption: this.ecosystemCaption(card, foundationLabel), testId: `project-detail-ecosystem-card-${card.key}`, }; } - private sparklineData(label: string, series: number[]): ChartData { + /** Sentence with an emphasized stat, split so the stat can render bold. */ + private technicalCaption(card: OrgLensProjectTechnicalCard): { prefix: string; emphasis: string; suffix: string } { + const pct = `${(card.pct * 100).toFixed(1)}%`; + switch (card.key) { + case 'maintainers': + return card.orgCount === 0 + ? { prefix: 'Our company employs ', emphasis: 'no', suffix: ' maintainers for this project.' } + : { + prefix: 'Our company employs ', + emphasis: `${card.orgCount}`, + suffix: ` ${card.orgCount === 1 ? 'maintainer' : 'maintainers'} for this project.`, + }; + case 'contributors': + return { prefix: 'Our company employs ', emphasis: pct, suffix: ' of contributors to this project.' }; + case 'commits': + return { prefix: 'Employees made ', emphasis: pct, suffix: ' of all commit activities.' }; + case 'pull-requests': + return { prefix: 'Employees opened ', emphasis: pct, suffix: ' of all pull requests.' }; + default: + return { prefix: card.label, emphasis: '', suffix: '' }; + } + } + + private ecosystemCaption(card: OrgLensProjectEcosystemCard, foundation: string): { prefix: string; emphasis: string; suffix: string } { + const pct = `${(card.pct * 100).toFixed(1)}%`; + switch (card.key) { + case 'collaboration': + return card.count === 0 + ? { prefix: 'No collaboration activity recorded for this project.', emphasis: '', suffix: '' } + : { prefix: 'Employees contributed ', emphasis: pct, suffix: ' of all collaboration activities.' }; + case 'meeting-attendance': + return card.count === 0 + ? { prefix: 'Our company has no meeting attendance for this project.', emphasis: '', suffix: '' } + : { prefix: 'Org reps attended ', emphasis: `${card.count}`, suffix: ` project ${card.count === 1 ? 'meeting' : 'meetings'}.` }; + case 'board-members': + return card.count === 0 + ? { prefix: `Your organization holds no board seats in ${foundation}.`, emphasis: '', suffix: '' } + : { prefix: 'Our company employs ', emphasis: `${card.count} board ${card.count === 1 ? 'member' : 'members'}`, suffix: ` for ${foundation}.` }; + case 'committee-members': + return card.count === 0 + ? { prefix: `Your organization holds no committee seats in ${foundation}.`, emphasis: '', suffix: '' } + : { prefix: 'Employees make up ', emphasis: pct, suffix: ' of all committee members.' }; + default: + return { prefix: card.label, emphasis: '', suffix: '' }; + } + } + + /** Dual-line card sparkline: the metric line in `colorHex` plus a faint gray reference baseline. */ + private cardChartData(series: number[], colorHex: string): ChartData { return { labels: this.monthLabels, datasets: [ + { data: series, borderColor: colorHex, backgroundColor: 'transparent', fill: false, tension: 0.4, borderWidth: 2, pointRadius: 0 }, { - label, - data: series, - borderColor: lfxColors.blue[500], - backgroundColor: hexToRgba(lfxColors.blue[500], 0.1), - fill: true, - tension: 0, - borderWidth: 2, + data: series.map((value) => Math.round(value * 0.8 * 10) / 10), + borderColor: lfxColors.gray[200], + backgroundColor: 'transparent', + fill: false, + tension: 0.4, + borderWidth: 1.5, pointRadius: 0, }, ], }; } - private sparklineOptions(label: string): ChartOptions { - return { - ...BASE_LINE_CHART_OPTIONS, - plugins: { - ...BASE_LINE_CHART_OPTIONS.plugins, - tooltip: { - ...(BASE_LINE_CHART_OPTIONS.plugins?.tooltip ?? {}), - callbacks: { - title: (context) => context[0]?.label ?? '', - label: (context) => `${label}: ${context.parsed.y ?? 0}`, - }, - }, - }, - }; - } - private buildTrendData(): ChartData { const trend = this.detail()?.trend ?? []; const labels = trend.map((point) => this.formatTrendMonth(point.month)); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 846b150a5..988e43cd6 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -257,7 +257,6 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] projectTotal: seed.totals.maintainers, pct: pctOf(seed.org.maintainers, seed.totals.maintainers), sparkline: ramp(seed.org.maintainers, 0.6), - dataUpdatedHoursAgo: 9, }, { key: 'contributors', @@ -266,7 +265,6 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] projectTotal: seed.totals.contributors, pct: pctOf(seed.org.contributors, seed.totals.contributors), sparkline: ramp(seed.org.contributors, 0.55), - dataUpdatedHoursAgo: 1, }, { key: 'commits', @@ -275,7 +273,6 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] projectTotal: seed.totals.commits, pct: pctOf(seed.org.commits, seed.totals.commits), sparkline: ramp(seed.org.commits, 0.65), - dataUpdatedHoursAgo: 1, }, { key: 'pull-requests', @@ -284,17 +281,45 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] projectTotal: seed.totals.prs, pct: pctOf(seed.org.prs, seed.totals.prs), sparkline: ramp(seed.org.prs, 0.65), - dataUpdatedHoursAgo: 1, }, ]; } function ecosystemCards(seed: ProjectDetailSeed): OrgLensProjectEcosystemCard[] { + const collab = seed.ecosystem.collaboration; + const committee = seed.ecosystem.committeeMembers; return [ - { key: 'collaboration', label: 'Collaboration Activity', count: seed.ecosystem.collaboration, dataUpdatedHoursAgo: 9 }, - { key: 'meeting-attendance', label: 'Meeting Attendance', count: seed.ecosystem.meetingAttendance, dataUpdatedHoursAgo: 2 }, - { key: 'board-members', label: 'Board Members', count: seed.ecosystem.boardMembers, dataUpdatedHoursAgo: 3 }, - { key: 'committee-members', label: 'Committee Members', count: seed.ecosystem.committeeMembers, dataUpdatedHoursAgo: 3 }, + // Collaboration shows a "% of all" (denominator picked so Kubernetes lands at the prototype's 9.6%). + { + key: 'collaboration', + label: 'Collaboration Activity', + count: collab, + pct: collab === 0 ? 0 : Math.round((collab / 15333) * 1000) / 1000, + sparkline: ramp(collab, 0.6), + }, + // Meeting attendance is a raw count; 0 → no trendline ("No data"). + { + key: 'meeting-attendance', + label: 'Meeting Attendance', + count: seed.ecosystem.meetingAttendance, + pct: 0, + sparkline: seed.ecosystem.meetingAttendance === 0 ? [] : ramp(seed.ecosystem.meetingAttendance, 0.6), + }, + { + key: 'board-members', + label: 'Board Members', + count: seed.ecosystem.boardMembers, + pct: 0, + sparkline: seed.ecosystem.boardMembers === 0 ? [] : ramp(seed.ecosystem.boardMembers, 0.6), + }, + // Committee members shows a "% of all" (denominator picked so Kubernetes lands at the prototype's 1.1%). + { + key: 'committee-members', + label: 'Committee Members', + count: committee, + pct: committee === 0 ? 0 : Math.round((committee / 364) * 1000) / 1000, + sparkline: committee === 0 ? [] : ramp(committee, 0.6), + }, ]; } diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index 3db002c93..cf7e9825d 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -59,18 +59,21 @@ export interface OrgLensProjectTechnicalCard { pct: number; /** 12 monthly bins, oldest → newest. */ sparkline: number[]; - dataUpdatedHoursAgo: number; } /** * Ecosystem-group card (Collaboration Activity / Meeting Attendance / Board Members / - * Committee Members). A single count; board/committee are point-in-time seats held. + * Committee Members). Board/committee are foundation-level point-in-time seats; collaboration + * and meetings are project-level. An empty `sparkline` renders a "No data" state. */ export interface OrgLensProjectEcosystemCard { key: 'collaboration' | 'meeting-attendance' | 'board-members' | 'committee-members'; label: string; count: number; - dataUpdatedHoursAgo: number; + /** 0..1 "% of all" share, where applicable (collaboration, committee members); 0 otherwise. */ + pct: number; + /** 12 monthly bins, oldest → newest. Empty array → no trendline data available. */ + sparkline: number[]; } /** One monthly point on the Influence Trend chart. */ From 7ea46d5b98d41f1d0391bba5ce2c0ba395863b8b Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 17:35:06 -0600 Subject: [PATCH 10/80] refactor(org-lens): align project hero with My Org design Match the My Org project hero: fold the source link into the description as an inline 'Source: LFX Insights' link, label the tiles First commit / Software value / Health score, and use a two-segment 'Projects / ' breadcrumb. Add the Foundation tile that the My Org design omits. Update the Kubernetes demo values to the design (first commit July 2013, software value $6B) and full-month formatting. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 34 ++++++------------- .../org-project-detail.component.ts | 6 ++-- .../org-lens-project-detail.demo-data.ts | 6 ++-- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 8c5691cbe..b6705c87a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -60,36 +60,26 @@ }

{{ h.projectName }}

-

{{ h.description }}

-
- @if (h.sourceUrl) { - - - {{ h.sourceLabel || 'Source' }} - - } +

+ {{ h.description }} @if (h.lfxInsightsUrl) { + Source: - - View on LFX Insights + data-testid="project-detail-source-link"> + LFX Insights + } -

+

-
+
First commit {{ firstCommitLabel() }} @@ -105,7 +95,7 @@

{{ softwareValueLabel() }}

- Health + Health score @if (healthMeta(); as hm) { @@ -114,11 +104,7 @@

Foundation - {{ h.foundationLabel }} + {{ h.foundationLabel }}

diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index e6c5779f0..3308c8d05 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -285,14 +285,14 @@ export class OrgProjectDetailComponent { private initBreadcrumb(): MenuItem[] { const hero = this.hero(); - if (!hero) return [{ label: 'Projects', routerLink: ['/org/projects'] }]; - return [{ label: 'Projects', routerLink: ['/org/projects'] }, { label: hero.foundationLabel }, { label: hero.projectName }]; + const root: MenuItem = { label: 'Projects', routerLink: ['/org/projects'] }; + return hero ? [root, { label: hero.projectName }] : [root]; } private formatMonthYear(dateString: string | null): string { if (!dateString) return '—'; try { - return parseLocalDateString(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); + return parseLocalDateString(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); } catch { return dateString; } diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 988e43cd6..c4a106427 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -41,12 +41,12 @@ interface ProjectDetailSeed { const SEEDS: Record = { kubernetes: { name: 'Kubernetes', - description: 'Production-grade container orchestration.', + description: 'Kubernetes (K8s) is an open-source system for automating deployment, scaling, and management of containerized applications.', foundationLabel: 'Cloud Native Computing Foundation', health: 'excellent', sourceUrl: 'https://github.com/kubernetes/kubernetes', - firstCommit: '2014-06-06', - softwareValueUsd: 1284000000, + firstCommit: '2013-07-01', + softwareValueUsd: 6000000000, org: { maintainers: 5, contributors: 6, commits: 1840, prs: 624 }, totals: { maintainers: 78, contributors: 612, commits: 32940, prs: 11280 }, ecosystem: { collaboration: 1472, meetingAttendance: 7, boardMembers: 1, committeeMembers: 4 }, From 1d3d08395f9685068e22aa1ee86dd80eae5f8236 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 17:50:26 -0600 Subject: [PATCH 11/80] refactor(org-lens): drop 'Project' suffix from ecosystem scope label Show the bare project name (e.g. 'Kubernetes') on project-level ecosystem cards instead of 'Kubernetes Project'; foundation-level cards keep the foundation name. Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 3308c8d05..52550112c 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -336,7 +336,7 @@ export class OrgProjectDetailComponent { const hero = this.hero(); const projectName = hero?.projectName ?? 'this project'; const foundationLabel = hero?.foundationLabel ?? 'the foundation'; - const scopeLabel = ECOSYSTEM_SCOPE[card.key] === 'project' ? `${projectName} Project` : foundationLabel; + const scopeLabel = ECOSYSTEM_SCOPE[card.key] === 'project' ? projectName : foundationLabel; return { key: card.key, title: card.label, From d11cc7b0029c4a0eb552d0229aa6a4fff0b7dfa6 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 9 Jun 2026 01:03:17 -0600 Subject: [PATCH 12/80] feat(org-lens): full influence card set (5 technical, 9 ecosystem) Match the My Org design's complete Our Influence set. Add Avg Time to Merge PRs (technical) and Event Attendance, Event Speakers, Event Sponsorships, Meetup Attendance, and Certified Individuals (ecosystem). Unify the two card types into a single OrgLensProjectInfluenceCard whose descriptive caption + scope label are built in the demo data (where the foundation name is known), so the component just renders prefix/emphasis/suffix + trendline. Project-level cards (collaboration, meetings) label with the project name; the rest with the foundation name. Signed-off-by: daniel qualls --- .../org-project-detail.component.ts | 92 ++------ .../org-lens-project-detail.demo-data.ts | 203 +++++++++++------- .../org-lens-project-detail.interface.ts | 39 ++-- 3 files changed, 156 insertions(+), 178 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 52550112c..dba6982aa 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -20,9 +20,8 @@ import type { OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, OrgLensProjectDetailTab, - OrgLensProjectEcosystemCard, OrgLensProjectHealth, - OrgLensProjectTechnicalCard, + OrgLensProjectInfluenceCard, TagSeverity, } from '@lfx-one/shared/interfaces'; import { parseLocalDateString } from '@lfx-one/shared/utils'; @@ -49,14 +48,6 @@ const HEALTH_TAG: Record = { - collaboration: 'project', - 'meeting-attendance': 'project', - 'board-members': 'foundation', - 'committee-members': 'foundation', -}; - /** Leaderboard band chip → lfx-tag severity. */ const BAND_TAG: Record = { leading: { label: 'Leading', severity: 'success' }, @@ -135,8 +126,12 @@ export class OrgProjectDetailComponent { // Our Influence tab — Technical + Ecosystem cards (trendline + sentence), same card style. private readonly monthLabels: string[] = this.buildMonthLabels(); protected readonly cardChartOptions: ChartOptions = { ...BASE_LINE_CHART_OPTIONS }; - protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toTechnicalCard(card))); - protected readonly ecosystemCards = computed(() => (this.detail()?.ecosystem ?? []).map((card) => this.toEcosystemCard(card))); + protected readonly technicalCards = computed(() => + (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')) + ); + protected readonly ecosystemCards = computed(() => + (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem')) + ); // Influence Trend chart — Combined / Technical / Ecosystem overlays (legend toggles each line). protected readonly hasTrendHistory = computed(() => (this.detail()?.trend.length ?? 0) >= 3); @@ -320,81 +315,18 @@ export class OrgProjectDetailComponent { return out; } - private toTechnicalCard(card: OrgLensProjectTechnicalCard) { - return { - key: card.key, - title: card.label, - scopeLabel: null as string | null, - hasData: card.sparkline.length > 0, - chartData: this.cardChartData(card.sparkline, lfxColors.blue[500]), - caption: this.technicalCaption(card), - testId: `project-detail-technical-card-${card.key}`, - }; - } - - private toEcosystemCard(card: OrgLensProjectEcosystemCard) { - const hero = this.hero(); - const projectName = hero?.projectName ?? 'this project'; - const foundationLabel = hero?.foundationLabel ?? 'the foundation'; - const scopeLabel = ECOSYSTEM_SCOPE[card.key] === 'project' ? projectName : foundationLabel; + private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem') { return { key: card.key, title: card.label, - scopeLabel, + scopeLabel: card.scopeLabel, hasData: card.sparkline.length > 0, - chartData: this.cardChartData(card.sparkline, lfxColors.violet[500]), - caption: this.ecosystemCaption(card, foundationLabel), - testId: `project-detail-ecosystem-card-${card.key}`, + chartData: this.cardChartData(card.sparkline, colorHex), + caption: card.caption, + testId: `project-detail-${group}-card-${card.key}`, }; } - /** Sentence with an emphasized stat, split so the stat can render bold. */ - private technicalCaption(card: OrgLensProjectTechnicalCard): { prefix: string; emphasis: string; suffix: string } { - const pct = `${(card.pct * 100).toFixed(1)}%`; - switch (card.key) { - case 'maintainers': - return card.orgCount === 0 - ? { prefix: 'Our company employs ', emphasis: 'no', suffix: ' maintainers for this project.' } - : { - prefix: 'Our company employs ', - emphasis: `${card.orgCount}`, - suffix: ` ${card.orgCount === 1 ? 'maintainer' : 'maintainers'} for this project.`, - }; - case 'contributors': - return { prefix: 'Our company employs ', emphasis: pct, suffix: ' of contributors to this project.' }; - case 'commits': - return { prefix: 'Employees made ', emphasis: pct, suffix: ' of all commit activities.' }; - case 'pull-requests': - return { prefix: 'Employees opened ', emphasis: pct, suffix: ' of all pull requests.' }; - default: - return { prefix: card.label, emphasis: '', suffix: '' }; - } - } - - private ecosystemCaption(card: OrgLensProjectEcosystemCard, foundation: string): { prefix: string; emphasis: string; suffix: string } { - const pct = `${(card.pct * 100).toFixed(1)}%`; - switch (card.key) { - case 'collaboration': - return card.count === 0 - ? { prefix: 'No collaboration activity recorded for this project.', emphasis: '', suffix: '' } - : { prefix: 'Employees contributed ', emphasis: pct, suffix: ' of all collaboration activities.' }; - case 'meeting-attendance': - return card.count === 0 - ? { prefix: 'Our company has no meeting attendance for this project.', emphasis: '', suffix: '' } - : { prefix: 'Org reps attended ', emphasis: `${card.count}`, suffix: ` project ${card.count === 1 ? 'meeting' : 'meetings'}.` }; - case 'board-members': - return card.count === 0 - ? { prefix: `Your organization holds no board seats in ${foundation}.`, emphasis: '', suffix: '' } - : { prefix: 'Our company employs ', emphasis: `${card.count} board ${card.count === 1 ? 'member' : 'members'}`, suffix: ` for ${foundation}.` }; - case 'committee-members': - return card.count === 0 - ? { prefix: `Your organization holds no committee seats in ${foundation}.`, emphasis: '', suffix: '' } - : { prefix: 'Employees make up ', emphasis: pct, suffix: ' of all committee members.' }; - default: - return { prefix: card.label, emphasis: '', suffix: '' }; - } - } - /** Dual-line card sparkline: the metric line in `colorHex` plus a faint gray reference baseline. */ private cardChartData(series: number[], colorHex: string): ChartData { return { diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index c4a106427..a2e20d767 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -5,10 +5,9 @@ import type { OrgLensProjectDetailResponse, - OrgLensProjectEcosystemCard, OrgLensProjectHealth, + OrgLensProjectInfluenceCard, OrgLensProjectLeaderboardRow, - OrgLensProjectTechnicalCard, OrgLensProjectTrendPoint, } from '@lfx-one/shared/interfaces'; @@ -244,82 +243,142 @@ function ramp(end: number, startFactor: number, round = 0): number[] { return Array.from({ length: 12 }, (_, i) => Math.round((start + step * i) * factor) / factor); } -function pctOf(n: number, total: number): number { - return total === 0 ? 0 : Math.round((n / total) * 1000) / 1000; +function pctStr(value: number): string { + return `${(value * 100).toFixed(1)}%`; } -function technicalCards(seed: ProjectDetailSeed): OrgLensProjectTechnicalCard[] { +function shareOf(n: number, total: number): number { + return total === 0 ? 0 : n / total; +} + +interface Caption { prefix: string; emphasis: string; suffix: string } + +function card(key: string, label: string, scopeLabel: string | null, sparkline: number[], caption: Caption): OrgLensProjectInfluenceCard { + return { key, label, scopeLabel, sparkline, caption }; +} + +/** Five technical cards (project-level): contribution metrics + PR merge speed. */ +function technicalCards(seed: ProjectDetailSeed): OrgLensProjectInfluenceCard[] { + const { org, totals } = seed; + // "% slower than average" derived from the technical influence score (lower score → slower merges). + const mergeSlower = Math.round((100 - seed.influence.technical) * 0.4 * 100) / 100; return [ - { - key: 'maintainers', - label: 'Maintainers', - orgCount: seed.org.maintainers, - projectTotal: seed.totals.maintainers, - pct: pctOf(seed.org.maintainers, seed.totals.maintainers), - sparkline: ramp(seed.org.maintainers, 0.6), - }, - { - key: 'contributors', - label: 'Contributors', - orgCount: seed.org.contributors, - projectTotal: seed.totals.contributors, - pct: pctOf(seed.org.contributors, seed.totals.contributors), - sparkline: ramp(seed.org.contributors, 0.55), - }, - { - key: 'commits', - label: 'Commit Activities', - orgCount: seed.org.commits, - projectTotal: seed.totals.commits, - pct: pctOf(seed.org.commits, seed.totals.commits), - sparkline: ramp(seed.org.commits, 0.65), - }, - { - key: 'pull-requests', - label: 'Pull Requests', - orgCount: seed.org.prs, - projectTotal: seed.totals.prs, - pct: pctOf(seed.org.prs, seed.totals.prs), - sparkline: ramp(seed.org.prs, 0.65), - }, + card( + 'maintainers', + 'Maintainers', + null, + ramp(org.maintainers, 0.6), + org.maintainers === 0 + ? { prefix: 'Our company employs ', emphasis: 'no', suffix: ' maintainers for this project.' } + : { + prefix: 'Our company employs ', + emphasis: `${org.maintainers}`, + suffix: ` ${org.maintainers === 1 ? 'maintainer' : 'maintainers'} for this project.`, + } + ), + card('contributors', 'Contributors', null, ramp(org.contributors, 0.55), { + prefix: 'Our company employs ', + emphasis: pctStr(shareOf(org.contributors, totals.contributors)), + suffix: ' of contributors to this project.', + }), + card('commits', 'Commit Activities', null, ramp(org.commits, 0.65), { + prefix: 'Employees made ', + emphasis: pctStr(shareOf(org.commits, totals.commits)), + suffix: ' of all commit activities.', + }), + card('pull-requests', 'Pull Requests Opened', null, ramp(org.prs, 0.65), { + prefix: 'Employees opened ', + emphasis: pctStr(shareOf(org.prs, totals.prs)), + suffix: ' of all pull requests.', + }), + card('avg-merge-time', 'Avg Time to Merge PRs', null, ramp(100 - seed.influence.technical, 0.85, 1), { + prefix: 'PRs merged ', + emphasis: `${mergeSlower}% slower`, + suffix: ' than average.', + }), ]; } -function ecosystemCards(seed: ProjectDetailSeed): OrgLensProjectEcosystemCard[] { - const collab = seed.ecosystem.collaboration; - const committee = seed.ecosystem.committeeMembers; +/** Nine ecosystem cards: collaboration + meetings are project-level; the rest are foundation-level. */ +function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation: string): OrgLensProjectInfluenceCard[] { + const eco = seed.ecosystem; + const e = seed.influence.ecosystem; + // Denominators picked so Kubernetes lands on the prototype's 9.6% / 1.1%. + const collabPct = eco.collaboration === 0 ? 0 : Math.round((eco.collaboration / 15333) * 1000) / 1000; + const committeePct = eco.committeeMembers === 0 ? 0 : Math.round((eco.committeeMembers / 364) * 1000) / 1000; + // Foundation-level event/training shares scale off the ecosystem influence score. + const eventAttPct = Math.round((e / 100) * 0.95 * 1000) / 1000; + const speakerPct = Math.round((e / 100) * 0.038 * 1000) / 1000; + const sponsorPct = Math.round((e / 100) * 0.04 * 1000) / 1000; + const meetupPct = Math.round((e / 100) * 0.013 * 1000) / 1000; + const certifiedPct = Math.round((e / 100) * 0.005 * 1000) / 1000; + return [ - // Collaboration shows a "% of all" (denominator picked so Kubernetes lands at the prototype's 9.6%). - { - key: 'collaboration', - label: 'Collaboration Activity', - count: collab, - pct: collab === 0 ? 0 : Math.round((collab / 15333) * 1000) / 1000, - sparkline: ramp(collab, 0.6), - }, - // Meeting attendance is a raw count; 0 → no trendline ("No data"). - { - key: 'meeting-attendance', - label: 'Meeting Attendance', - count: seed.ecosystem.meetingAttendance, - pct: 0, - sparkline: seed.ecosystem.meetingAttendance === 0 ? [] : ramp(seed.ecosystem.meetingAttendance, 0.6), - }, - { - key: 'board-members', - label: 'Board Members', - count: seed.ecosystem.boardMembers, - pct: 0, - sparkline: seed.ecosystem.boardMembers === 0 ? [] : ramp(seed.ecosystem.boardMembers, 0.6), - }, - // Committee members shows a "% of all" (denominator picked so Kubernetes lands at the prototype's 1.1%). - { - key: 'committee-members', - label: 'Committee Members', - count: committee, - pct: committee === 0 ? 0 : Math.round((committee / 364) * 1000) / 1000, - sparkline: committee === 0 ? [] : ramp(committee, 0.6), - }, + card( + 'collaboration', + 'Collaboration Activity', + projectName, + ramp(eco.collaboration, 0.6), + eco.collaboration === 0 + ? { prefix: 'No collaboration activity recorded for this project.', emphasis: '', suffix: '' } + : { prefix: 'Employees contributed ', emphasis: pctStr(collabPct), suffix: ' of all collaboration activities.' } + ), + card( + 'meeting-attendance', + 'Meeting Attendance', + projectName, + eco.meetingAttendance === 0 ? [] : ramp(eco.meetingAttendance, 0.6), + eco.meetingAttendance === 0 + ? { prefix: 'Our company has no meeting attendance for this project.', emphasis: '', suffix: '' } + : { prefix: 'Org reps attended ', emphasis: `${eco.meetingAttendance}`, suffix: ` project ${eco.meetingAttendance === 1 ? 'meeting' : 'meetings'}.` } + ), + card( + 'board-members', + 'Board Members', + foundation, + eco.boardMembers === 0 ? [] : ramp(eco.boardMembers, 0.6), + eco.boardMembers === 0 + ? { prefix: `Your organization holds no board seats in ${foundation}.`, emphasis: '', suffix: '' } + : { + prefix: 'Our company employs ', + emphasis: `${eco.boardMembers} board ${eco.boardMembers === 1 ? 'member' : 'members'}`, + suffix: ` for ${foundation}.`, + } + ), + card( + 'committee-members', + 'Committee Members', + foundation, + eco.committeeMembers === 0 ? [] : ramp(eco.committeeMembers, 0.6), + eco.committeeMembers === 0 + ? { prefix: `Your organization holds no committee seats in ${foundation}.`, emphasis: '', suffix: '' } + : { prefix: 'Employees make up ', emphasis: pctStr(committeePct), suffix: ' of all committee members.' } + ), + card('event-attendance', 'Event Attendance', foundation, ramp(eventAttPct * 100, 0.78, 1), { + prefix: 'Employees attended ', + emphasis: pctStr(eventAttPct), + suffix: ` of all ${foundation} events.`, + }), + card('event-speakers', 'Event Speakers', foundation, ramp(speakerPct * 100, 0.78, 2), { + prefix: 'Employees represented ', + emphasis: pctStr(speakerPct), + suffix: ` of all speakers at ${foundation} events.`, + }), + card('event-sponsorships', 'Event Sponsorships', foundation, ramp(sponsorPct * 100, 0.78, 2), { + prefix: 'Our company reached ', + emphasis: pctStr(sponsorPct), + suffix: ' of attendees through sponsorship.', + }), + card('meetup-attendance', 'Meetup Attendance', foundation, ramp(meetupPct * 100, 0.78, 2), { + prefix: 'Employees attended ', + emphasis: pctStr(meetupPct), + suffix: ` of all ${foundation} meetups.`, + }), + card('certified-individuals', 'Certified Individuals', foundation, ramp(certifiedPct * 100, 0.78, 2), { + prefix: 'Employees make up ', + emphasis: pctStr(certifiedPct), + suffix: ' of all certified individuals.', + }), ]; } @@ -414,7 +473,7 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu lastUpdated: new Date(Date.now() - 60 * 60 * 1000).toISOString(), }, technical: technicalCards(seed), - ecosystem: ecosystemCards(seed), + ecosystem: ecosystemCards(seed, seed.name, seed.foundationLabel), trend: trendSeries(seed), leaderboard: leaderboard(seed, orgName), }; diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index cf7e9825d..29cd63698 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -47,33 +47,20 @@ export interface OrgLensProjectHero { } /** - * Technical-group card (Maintainers / Contributors / Commit Activities / Pull Requests). - * `% of all` = orgCount / projectTotal over a rolling 365-day window. + * A single Our-Influence metric card, used for both the Technical and Ecosystem groups + * (Maintainers, Contributors, …, Event Speakers, Certified Individuals, …). Each renders a + * title, a 12-month trendline, and a descriptive sentence; the sentence is pre-split so the + * stat renders bold. An empty `sparkline` shows a "No data" state. */ -export interface OrgLensProjectTechnicalCard { - key: 'maintainers' | 'contributors' | 'commits' | 'pull-requests'; +export interface OrgLensProjectInfluenceCard { + key: string; label: string; - orgCount: number; - projectTotal: number; - /** 0..1 share of the project total attributable to org-affiliated authors. */ - pct: number; - /** 12 monthly bins, oldest → newest. */ - sparkline: number[]; -} - -/** - * Ecosystem-group card (Collaboration Activity / Meeting Attendance / Board Members / - * Committee Members). Board/committee are foundation-level point-in-time seats; collaboration - * and meetings are project-level. An empty `sparkline` renders a "No data" state. - */ -export interface OrgLensProjectEcosystemCard { - key: 'collaboration' | 'meeting-attendance' | 'board-members' | 'committee-members'; - label: string; - count: number; - /** 0..1 "% of all" share, where applicable (collaboration, committee members); 0 otherwise. */ - pct: number; - /** 12 monthly bins, oldest → newest. Empty array → no trendline data available. */ + /** Source shown above the card for ecosystem metrics (project name or foundation name); null for technical. */ + scopeLabel: string | null; + /** 12 monthly bins, oldest → newest. Empty array → "No data". */ sparkline: number[]; + /** Descriptive sentence split so the middle stat can render bold. */ + caption: { prefix: string; emphasis: string; suffix: string }; } /** One monthly point on the Influence Trend chart. */ @@ -110,8 +97,8 @@ export interface OrgLensProjectDetailResponse { accountId: string; projectSlug: string; hero: OrgLensProjectHero; - technical: OrgLensProjectTechnicalCard[]; - ecosystem: OrgLensProjectEcosystemCard[]; + technical: OrgLensProjectInfluenceCard[]; + ecosystem: OrgLensProjectInfluenceCard[]; trend: OrgLensProjectTrendPoint[]; /** All organizations contributing to the project; the viewing-org row is always included. */ leaderboard: OrgLensProjectLeaderboardRow[]; From ddfac97c78cbf7e8a242279f81ca1a030f59bdd0 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 13:03:42 -0400 Subject: [PATCH 13/80] fix(org-lens): show org name context and load without selected account - Add orgName computed signal (falls back to 'Acme Corp' for demo) - Show org-name chip above project title in hero - Remove orgUid filter so page loads without an org in context; use 'demo-org' / 'Acme Corp' fallbacks for the demo-data phase LFXV2-1885 Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 6 ++++++ .../org-project-detail.component.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index b6705c87a..86e37f1c8 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -59,6 +59,12 @@ }
+
+ + + {{ orgName() }} + +

{{ h.projectName }}

{{ h.description }} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index dba6982aa..659fef519 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -100,6 +100,9 @@ export class OrgProjectDetailComponent { protected readonly activeTab: Signal = computed(() => this.initActiveTab()); protected readonly pageState: Signal = computed(() => this.initPageState()); + /** The viewing org's display name — falls back to a demo placeholder when no org is selected. */ + protected readonly orgName = computed(() => this.accountContext.selectedAccount()?.accountName ?? 'Acme Corp'); + // Hero presentation — derived from the loaded payload. protected readonly hero = computed(() => this.detail()?.hero ?? null); protected readonly breadcrumbItems = computed(() => this.initBreadcrumb()); @@ -243,17 +246,17 @@ export class OrgProjectDetailComponent { } private initDetailStream(): Observable { - const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid)); + const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid ?? 'demo-org')); + const orgName$ = toObservable(computed(() => this.accountContext.selectedAccount()?.accountName ?? 'Acme Corp')); const projectSlug$ = this.route.paramMap.pipe(map((params) => params.get('projectSlug'))); const retryTrigger$ = toObservable(this.retryTrigger); - return combineLatest([orgUid$.pipe(filter((id): id is string => !!id)), projectSlug$.pipe(filter((slug): slug is string => !!slug)), retryTrigger$]).pipe( + return combineLatest([orgUid$, orgName$, projectSlug$.pipe(filter((slug): slug is string => !!slug)), retryTrigger$]).pipe( tap(() => { this.fetchLoading.set(true); this.fetchError.set(false); }), - switchMap(([orgUid, projectSlug]) => { - const orgName = this.accountContext.selectedAccount()?.accountName ?? ''; + switchMap(([orgUid, orgName, projectSlug]) => { return this.detailService.getProjectDetail(orgUid, orgName, projectSlug).pipe( catchError(() => { this.fetchError.set(true); From c76b7980ac979ab8c00504dc03e84caec7003711 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 15:34:36 -0400 Subject: [PATCH 14/80] feat(org-lens): polish influence cards and sparkline tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Horizontal-scroll single row for Technical and Ecosystem metric cards - Section-header band chip includes signal-strength SVG bars icon - Remove "Get involved with this project" link from card section - Move Influence Trend chart to Leaderboards tab beneath the boards - Dual-line sparklines: blue = company data, grey = project average - Hover tooltip shows both "Your company" and "Project average" labels - Increase sparkline height h-14→h-20 so tooltip rows aren't clipped - Fix valkey TS2345 type cast and null-check on cache reads - Uncomment Projects nav entry and wire Overview project-row click Signed-off-by: daniel qualls --- .../main-layout/main-layout.component.ts | 4 +- ...view-foundations-and-projects.component.ts | 3 +- .../org-project-detail.component.html | 68 +++++----- .../org-project-detail.component.ts | 122 +++++++++++++++--- .../org-lens-project-detail.demo-data.ts | 49 +++++-- .../src/server/services/valkey.service.ts | 4 +- .../org-lens-project-detail.interface.ts | 2 + 7 files changed, 181 insertions(+), 71 deletions(-) diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index d14d7213d..2384b995a 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -509,9 +509,7 @@ export class MainLayoutComponent { icon: 'fa-light fa-display', routerLink: '/org/memberships', }, - // INFO: Future Epic implementation — the Projects page is hidden until the - // org-projects drilldown is built. Restore the entry below to re-enable it. - // { label: 'Projects', icon: 'fa-light fa-folder', routerLink: '/org/projects' }, + { label: 'Projects', icon: 'fa-light fa-folder', routerLink: '/org/projects' }, // INFO: Future Epic implementation — the ROI page is hidden until the org ROI // feature is built. Restore the entry below to re-enable it. // { label: 'ROI', icon: 'fa-light fa-chart-line-up', routerLink: '/org/roi' }, diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts index 5324021b9..3413f8560 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts @@ -4,7 +4,7 @@ import { DecimalPipe } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { SkeletonModule } from 'primeng/skeleton'; import { AccountContextService } from '@services/account-context.service'; import { OrgLensFoundationsService } from '@services/org-lens-foundations.service'; @@ -28,6 +28,7 @@ export class OrgOverviewFoundationsAndProjectsComponent { private readonly accountContextService = inject(AccountContextService); private readonly foundationsService = inject(OrgLensFoundationsService); private readonly plausibleService = inject(PlausibleService); + private readonly router = inject(Router); private readonly retryTrigger = signal(0); private readonly expansionState = signal>({}); diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 86e37f1c8..153e7d3a8 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -156,56 +156,46 @@

Our Technical Influence

- @if (technicalBand(); as b) { - + @if (technicalBand(); as band) { + + + {{ bandLabel(band) }} + }
-
+
@for (card of technicalCards(); track card.key) { }
- @if (sourceUrl()) { - - - Get involved with this project - - }

Our Ecosystem Influence

- @if (ecosystemBand(); as b) { - + @if (ecosystemBand(); as band) { + + + {{ bandLabel(band) }} + }
-
+
@for (card of ecosystemCards(); track card.key) { }
- -
-

Influence Trend

-
- @if (hasTrendHistory()) { -
- -
- } @else { -

Not enough history yet.

- } -
-
} @case ('pd-leaderboards') { @@ -257,6 +247,20 @@

Influence Trend

testid: 'ecosystem', }"> + + +
+

Influence Trend

+
+ @if (hasTrendHistory()) { +
+ +
+ } @else { +

Not enough history yet.

+ } +
+
} } @@ -267,13 +271,13 @@

Influence Trend

-
+
@if (card.scopeLabel) {
{{ card.scopeLabel }}
}
{{ card.title }}
-
+
@if (card.hasData) { } @else { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 659fef519..de7673fd8 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -56,6 +56,36 @@ const BAND_TAG: Record = { + leading: 4, + contributing: 3, + participating: 2, + 'non-lf': 0, +}; +const BAND_SIGNAL_FILL: Record = { + leading: 'fill-emerald-500', + contributing: 'fill-blue-500', + participating: 'fill-amber-500', + 'non-lf': 'fill-gray-400', +}; +const BAND_SIGNAL_FILL_LIGHT: Record = { + leading: 'fill-emerald-200', + contributing: 'fill-blue-200', + participating: 'fill-amber-200', + 'non-lf': 'fill-gray-200', +}; +const BAND_CHIP_CLASS: Record = { + leading: 'inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700', + contributing: 'inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700', + participating: 'inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700', + 'non-lf': 'inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600', +}; + const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string }[] = [ { id: 'influence', label: 'Calculated Influence' }, { id: 'activity', label: 'Activity Count' }, @@ -119,16 +149,16 @@ export class OrgProjectDetailComponent { private readonly viewingScores = computed(() => this.detail()?.leaderboard.find((row) => row.isViewingOrg)?.scores ?? null); protected readonly technicalBand = computed(() => { const scores = this.viewingScores(); - return scores ? BAND_TAG[bandForScore(scores.technical)] : null; + return scores ? bandForScore(scores.technical) : null; }); protected readonly ecosystemBand = computed(() => { const scores = this.viewingScores(); - return scores ? BAND_TAG[bandForScore(scores.ecosystem)] : null; + return scores ? bandForScore(scores.ecosystem) : null; }); // Our Influence tab — Technical + Ecosystem cards (trendline + sentence), same card style. private readonly monthLabels: string[] = this.buildMonthLabels(); - protected readonly cardChartOptions: ChartOptions = { ...BASE_LINE_CHART_OPTIONS }; + protected readonly cardChartOptions: ChartOptions = this.buildCardChartOptions(); protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')) ); @@ -200,6 +230,27 @@ export class OrgProjectDetailComponent { (dimension === 'technical' ? this.techSearch : this.ecoSearch).set(value); } + protected bandChipClass(band: OrgLensProjectBand): string { + return BAND_CHIP_CLASS[band]; + } + + protected bandLabel(band: OrgLensProjectBand): string { + return BAND_TAG[band].label; + } + + protected bandSignalBars(band: OrgLensProjectBand): { x: number; y: number; h: number; fillClass: string }[] { + const rank = BAND_SIGNAL_RANK[band]; + const heights = [5, 8.3, 11.6, 15]; + const barWidth = 2.6; + const gap = 1.8; + return heights.map((h, i) => ({ + x: i * (barWidth + gap), + y: 16 - h, + h, + fillClass: i < rank ? BAND_SIGNAL_FILL[band] : BAND_SIGNAL_FILL_LIGHT[band], + })); + } + private initMetric(): OrgLensLeaderboardMetric { const raw = this.queryParamMap().get('metric'); return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; @@ -308,6 +359,23 @@ export class OrgProjectDetailComponent { return (parts[0][0] + parts[1][0]).toUpperCase(); } + /** Card sparkline options: extends the base line config with labeled datasets for the hover tooltip. */ + private buildCardChartOptions(): ChartOptions { + return { + ...BASE_LINE_CHART_OPTIONS, + plugins: { + legend: { display: false }, + tooltip: { + ...BASE_LINE_CHART_OPTIONS.plugins?.tooltip, + displayColors: true, + usePointStyle: true, + boxWidth: 8, + boxHeight: 8, + }, + }, + }; + } + /** Twelve trailing short-month labels (oldest → newest) for sparkline + trend tooltips. */ private buildMonthLabels(): string[] { const out: string[] = []; @@ -324,29 +392,41 @@ export class OrgProjectDetailComponent { title: card.label, scopeLabel: card.scopeLabel, hasData: card.sparkline.length > 0, - chartData: this.cardChartData(card.sparkline, colorHex), + chartData: this.cardChartData(card.sparkline, card.projectSparkline, colorHex), caption: card.caption, testId: `project-detail-${group}-card-${card.key}`, }; } - /** Dual-line card sparkline: the metric line in `colorHex` plus a faint gray reference baseline. */ - private cardChartData(series: number[], colorHex: string): ChartData { - return { - labels: this.monthLabels, - datasets: [ - { data: series, borderColor: colorHex, backgroundColor: 'transparent', fill: false, tension: 0.4, borderWidth: 2, pointRadius: 0 }, - { - data: series.map((value) => Math.round(value * 0.8 * 10) / 10), - borderColor: lfxColors.gray[200], - backgroundColor: 'transparent', - fill: false, - tension: 0.4, - borderWidth: 1.5, - pointRadius: 0, - }, - ], - }; + /** Dual-line card sparkline: the org metric line in `colorHex` plus a grey project-average reference. */ + private cardChartData(series: number[], projectSeries: number[], colorHex: string): ChartData { + const datasets: ChartData['datasets'] = [ + { + label: 'Your company', + data: series, + borderColor: colorHex, + backgroundColor: 'transparent', + fill: false, + tension: 0.4, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + }, + ]; + if (projectSeries.length > 0) { + datasets.push({ + label: 'Project average', + data: projectSeries, + borderColor: lfxColors.gray[300], + backgroundColor: 'transparent', + fill: false, + tension: 0.4, + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 4, + }); + } + return { labels: this.monthLabels, datasets }; } private buildTrendData(): ChartData { diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index a2e20d767..63feefd55 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -253,21 +253,37 @@ function shareOf(n: number, total: number): number { interface Caption { prefix: string; emphasis: string; suffix: string } -function card(key: string, label: string, scopeLabel: string | null, sparkline: number[], caption: Caption): OrgLensProjectInfluenceCard { - return { key, label, scopeLabel, sparkline, caption }; +function card( + key: string, + label: string, + scopeLabel: string | null, + sparkline: number[], + projectSparkline: number[], + caption: Caption +): OrgLensProjectInfluenceCard { + return { key, label, scopeLabel, sparkline, projectSparkline, caption }; } /** Five technical cards (project-level): contribution metrics + PR merge speed. */ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectInfluenceCard[] { const { org, totals } = seed; - // "% slower than average" derived from the technical influence score (lower score → slower merges). const mergeSlower = Math.round((100 - seed.influence.technical) * 0.4 * 100) / 100; + // Project-average series: derived from totals divided by estimated active-contributor counts. + // Maintainers are concentrated (~15 orgs each with meaningful presence); contributors are spread + // across ~90 orgs; commits/PRs are driven by ~20 high-volume orgs. + const projMaintainers = ramp(totals.maintainers / 15, 0.7); + const projContributors = ramp(totals.contributors / 90, 0.7); + const projCommits = ramp(totals.commits / 20, 0.7); + const projPrs = ramp(totals.prs / 20, 0.7); + // Merge-time baseline: 50 represents a project-neutral average (0=instant, 100=very slow). + const projMergeTime = ramp(50, 0.92, 1); return [ card( 'maintainers', 'Maintainers', null, ramp(org.maintainers, 0.6), + projMaintainers, org.maintainers === 0 ? { prefix: 'Our company employs ', emphasis: 'no', suffix: ' maintainers for this project.' } : { @@ -276,22 +292,22 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectInfluenceCard[] suffix: ` ${org.maintainers === 1 ? 'maintainer' : 'maintainers'} for this project.`, } ), - card('contributors', 'Contributors', null, ramp(org.contributors, 0.55), { + card('contributors', 'Contributors', null, ramp(org.contributors, 0.55), projContributors, { prefix: 'Our company employs ', emphasis: pctStr(shareOf(org.contributors, totals.contributors)), suffix: ' of contributors to this project.', }), - card('commits', 'Commit Activities', null, ramp(org.commits, 0.65), { + card('commits', 'Commit Activities', null, ramp(org.commits, 0.65), projCommits, { prefix: 'Employees made ', emphasis: pctStr(shareOf(org.commits, totals.commits)), suffix: ' of all commit activities.', }), - card('pull-requests', 'Pull Requests Opened', null, ramp(org.prs, 0.65), { + card('pull-requests', 'Pull Requests Opened', null, ramp(org.prs, 0.65), projPrs, { prefix: 'Employees opened ', emphasis: pctStr(shareOf(org.prs, totals.prs)), suffix: ' of all pull requests.', }), - card('avg-merge-time', 'Avg Time to Merge PRs', null, ramp(100 - seed.influence.technical, 0.85, 1), { + card('avg-merge-time', 'Avg Time to Merge PRs', null, ramp(100 - seed.influence.technical, 0.85, 1), projMergeTime, { prefix: 'PRs merged ', emphasis: `${mergeSlower}% slower`, suffix: ' than average.', @@ -312,6 +328,11 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation const sponsorPct = Math.round((e / 100) * 0.04 * 1000) / 1000; const meetupPct = Math.round((e / 100) * 0.013 * 1000) / 1000; const certifiedPct = Math.round((e / 100) * 0.005 * 1000) / 1000; + // Project-average proxies for ecosystem metrics (org is expected to be above avg for its band). + const projCollabAvg = ramp(eco.collaboration > 0 ? 15333 / 15 : 0, 0.65); + const projMeetingAvg = eco.meetingAttendance === 0 ? [] : ramp(eco.meetingAttendance * 0.78, 0.65); + const projBoardAvg = eco.boardMembers === 0 ? [] : ramp(eco.boardMembers * 0.7, 0.65); + const projCommitteeAvg = eco.committeeMembers === 0 ? [] : ramp(eco.committeeMembers * 0.8, 0.65); return [ card( @@ -319,6 +340,7 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation 'Collaboration Activity', projectName, ramp(eco.collaboration, 0.6), + projCollabAvg, eco.collaboration === 0 ? { prefix: 'No collaboration activity recorded for this project.', emphasis: '', suffix: '' } : { prefix: 'Employees contributed ', emphasis: pctStr(collabPct), suffix: ' of all collaboration activities.' } @@ -328,6 +350,7 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation 'Meeting Attendance', projectName, eco.meetingAttendance === 0 ? [] : ramp(eco.meetingAttendance, 0.6), + projMeetingAvg, eco.meetingAttendance === 0 ? { prefix: 'Our company has no meeting attendance for this project.', emphasis: '', suffix: '' } : { prefix: 'Org reps attended ', emphasis: `${eco.meetingAttendance}`, suffix: ` project ${eco.meetingAttendance === 1 ? 'meeting' : 'meetings'}.` } @@ -337,6 +360,7 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation 'Board Members', foundation, eco.boardMembers === 0 ? [] : ramp(eco.boardMembers, 0.6), + projBoardAvg, eco.boardMembers === 0 ? { prefix: `Your organization holds no board seats in ${foundation}.`, emphasis: '', suffix: '' } : { @@ -350,31 +374,32 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation 'Committee Members', foundation, eco.committeeMembers === 0 ? [] : ramp(eco.committeeMembers, 0.6), + projCommitteeAvg, eco.committeeMembers === 0 ? { prefix: `Your organization holds no committee seats in ${foundation}.`, emphasis: '', suffix: '' } : { prefix: 'Employees make up ', emphasis: pctStr(committeePct), suffix: ' of all committee members.' } ), - card('event-attendance', 'Event Attendance', foundation, ramp(eventAttPct * 100, 0.78, 1), { + card('event-attendance', 'Event Attendance', foundation, ramp(eventAttPct * 100, 0.78, 1), ramp(eventAttPct * 100 * 0.8, 0.72, 1), { prefix: 'Employees attended ', emphasis: pctStr(eventAttPct), suffix: ` of all ${foundation} events.`, }), - card('event-speakers', 'Event Speakers', foundation, ramp(speakerPct * 100, 0.78, 2), { + card('event-speakers', 'Event Speakers', foundation, ramp(speakerPct * 100, 0.78, 2), ramp(speakerPct * 100 * 0.8, 0.72, 2), { prefix: 'Employees represented ', emphasis: pctStr(speakerPct), suffix: ` of all speakers at ${foundation} events.`, }), - card('event-sponsorships', 'Event Sponsorships', foundation, ramp(sponsorPct * 100, 0.78, 2), { + card('event-sponsorships', 'Event Sponsorships', foundation, ramp(sponsorPct * 100, 0.78, 2), ramp(sponsorPct * 100 * 0.8, 0.72, 2), { prefix: 'Our company reached ', emphasis: pctStr(sponsorPct), suffix: ' of attendees through sponsorship.', }), - card('meetup-attendance', 'Meetup Attendance', foundation, ramp(meetupPct * 100, 0.78, 2), { + card('meetup-attendance', 'Meetup Attendance', foundation, ramp(meetupPct * 100, 0.78, 2), ramp(meetupPct * 100 * 0.8, 0.72, 2), { prefix: 'Employees attended ', emphasis: pctStr(meetupPct), suffix: ` of all ${foundation} meetups.`, }), - card('certified-individuals', 'Certified Individuals', foundation, ramp(certifiedPct * 100, 0.78, 2), { + card('certified-individuals', 'Certified Individuals', foundation, ramp(certifiedPct * 100, 0.78, 2), ramp(certifiedPct * 100 * 0.8, 0.72, 2), { prefix: 'Employees make up ', emphasis: pctStr(certifiedPct), suffix: ' of all certified individuals.', diff --git a/apps/lfx-one/src/server/services/valkey.service.ts b/apps/lfx-one/src/server/services/valkey.service.ts index 57cac9705..795f3babe 100644 --- a/apps/lfx-one/src/server/services/valkey.service.ts +++ b/apps/lfx-one/src/server/services/valkey.service.ts @@ -64,8 +64,8 @@ export class ValkeyService implements CachePort { public async getJson(key: string, accept?: (value: unknown) => boolean): Promise { if (!this.client) return null; try { - const raw = await this.withTimeout(this.client.get(key)); - if (raw === null) return null; + const raw = (await this.withTimeout(this.client.get(key))) as string | null; + if (raw == null) return null; // setJson caps our own writes, but another client (or a manual write) could store an oversized value. // Parsing a very large JSON string blocks the event loop, so reject oversized reads as a miss before parsing. if (Buffer.byteLength(raw, 'utf8') > VALKEY_CACHE.MAX_VALUE_BYTES) { diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index 29cd63698..b37740ce9 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -59,6 +59,8 @@ export interface OrgLensProjectInfluenceCard { scopeLabel: string | null; /** 12 monthly bins, oldest → newest. Empty array → "No data". */ sparkline: number[]; + /** Project-wide average monthly series (grey reference line). Same length as sparkline. */ + projectSparkline: number[]; /** Descriptive sentence split so the middle stat can render bold. */ caption: { prefix: string; emphasis: string; suffix: string }; } From 219841469187c8a80e8b8d5df7c007bd00f8be02 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 15:57:42 -0400 Subject: [PATCH 15/80] feat(org-lens): external HTML tooltip for sparkline cards Replace the built-in Chart.js canvas tooltip with an external HTML overlay (data-lfx-tip) so it is not constrained by the 80px canvas height. The overlay renders at full card width with readable 13px text, colored dot indicators, and bold values. Pointer-events-none lets the canvas receive hover events through the overlay. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 4 +- .../org-project-detail.component.ts | 37 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 153e7d3a8..444d822fd 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -275,7 +275,9 @@

Influence Trend

@if (card.scopeLabel) {
{{ card.scopeLabel }}
} -
+
+ +
{{ card.title }}
@if (card.hasData) { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index de7673fd8..74d12e8dc 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -359,18 +359,43 @@ export class OrgProjectDetailComponent { return (parts[0][0] + parts[1][0]).toUpperCase(); } - /** Card sparkline options: extends the base line config with labeled datasets for the hover tooltip. */ + /** + * Card sparkline options: uses an external HTML tooltip (data-lfx-tip) so the popup + * is not constrained by the canvas height and can render at a comfortable reading size. + */ private buildCardChartOptions(): ChartOptions { return { ...BASE_LINE_CHART_OPTIONS, plugins: { legend: { display: false }, tooltip: { - ...BASE_LINE_CHART_OPTIONS.plugins?.tooltip, - displayColors: true, - usePointStyle: true, - boxWidth: 8, - boxHeight: 8, + enabled: false, + external: ({ chart, tooltip }) => { + const tip = chart.canvas + .closest('[data-sparkline-host]') + ?.querySelector('[data-lfx-tip]'); + if (!tip) return; + + if (tooltip.opacity === 0) { + tip.style.display = 'none'; + return; + } + + const title = tooltip.title?.[0] ?? ''; + const rows = (tooltip.dataPoints ?? []) + .map( + (p) => + '
' + + `` + + `${p.dataset.label ?? ''}: ` + + `${p.formattedValue}` + + '
' + ) + .join(''); + + tip.innerHTML = `

${title}

${rows}`; + tip.style.display = 'block'; + }, }, }, }; From c3bb5280d2a92e50783ada494308160b98e45d57 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 16:07:57 -0400 Subject: [PATCH 16/80] feat(org-lens): hero cleanup, chip alignment, and card carousel arrows - Remove org-context chip from project hero (project page, not org page) - Show real project logo (Kubernetes/Argo CNCF artwork SVGs) - Fix hero stats layout: flex-wrap replaces compressed lg:grid-cols-4 - Fix band chip SVG: 15px viewBox centers bars flush with label text - Add arrow-button carousels (< >) to Technical and Ecosystem card rows Signed-off-by: Daniel Qualls Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 48 ++++++++++++------- .../org-project-detail.component.ts | 7 ++- .../org-lens-project-detail.demo-data.ts | 6 ++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 444d822fd..e5ca7c221 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -59,12 +59,6 @@
}
-
- - - {{ orgName() }} - -

{{ h.projectName }}

{{ h.description }} @@ -85,7 +79,7 @@

+
First commit {{ firstCommitLabel() }} @@ -158,7 +152,7 @@

Our Technical Influence

@if (technicalBand(); as band) { -

Our Technical Influence

}
-
- @for (card of technicalCards(); track card.key) { - - } +
+ +
+
+ @for (card of technicalCards(); track card.key) { + + } +
+
+
@@ -180,7 +184,7 @@

Our Technical Influence

Our Ecosystem Influence

@if (ecosystemBand(); as band) { -

Our Ecosystem Influence

}
-
- @for (card of ecosystemCards(); track card.key) { - - } +
+ +
+
+ @for (card of ecosystemCards(); track card.key) { + + } +
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 74d12e8dc..0e49d8c14 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -245,7 +245,7 @@ export class OrgProjectDetailComponent { const gap = 1.8; return heights.map((h, i) => ({ x: i * (barWidth + gap), - y: 16 - h, + y: 15 - h, h, fillClass: i < rank ? BAND_SIGNAL_FILL[band] : BAND_SIGNAL_FILL_LIGHT[band], })); @@ -352,6 +352,11 @@ export class OrgProjectDetailComponent { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); } + /** Scrolls a card track by one card slot (240 px = w-56 + gap-4). */ + protected scrollCards(el: HTMLElement, direction: 1 | -1): void { + el.scrollBy({ left: direction * 240, behavior: 'smooth' }); + } + private initialsFor(name: string): string { const parts = name.split(/[\s/]+/).filter(Boolean); if (parts.length === 0) return '?'; diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 63feefd55..20ef2a2ce 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -486,7 +486,11 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu hero: { projectName: seed.name, description: seed.description, - logoUrl: '', + logoUrl: projectSlug === 'kubernetes' + ? 'https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg' + : projectSlug === 'argo' + ? 'https://raw.githubusercontent.com/cncf/artwork/master/projects/argo/icon/color/argo-icon-color.svg' + : '', sourceUrl: seed.sourceUrl, sourceLabel: `${seed.name} - ${seed.description.replace(/\.$/, '')}`, lfxInsightsUrl: `https://insights.linuxfoundation.org/project/${projectSlug}`, From 3fac23a14f2d27a4a5660acd71344b6f5a3ea0e2 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 16:11:01 -0400 Subject: [PATCH 17/80] fix(org-lens): floating tooltip and hero identity layout - Tooltip: switch from absolute card-overlay to fixed viewport-positioned popup using getBoundingClientRect + caretX/caretY; tooltip now floats above the cursor rather than replacing card content - Hero: place logo and project name on the same row; description spans the full container width below instead of being constrained to the right of the name Signed-off-by: Daniel Qualls Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 50 +++++++++---------- .../org-project-detail.component.ts | 5 ++ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index e5ca7c221..94481ffe2 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -50,32 +50,32 @@ class="flex flex-col gap-6 rounded-lg border border-gray-200 bg-white p-6 md:flex-row md:items-start md:justify-between" data-testid="project-detail-hero"> -
- @if (h.logoUrl) { - - } @else { - - } -
+
+
+ @if (h.logoUrl) { + + } @else { + + }

{{ h.projectName }}

-

- {{ h.description }} - @if (h.lfxInsightsUrl) { - Source: - - LFX Insights - - - } -

+

+ {{ h.description }} + @if (h.lfxInsightsUrl) { + Source: + + LFX Insights + + + } +

@@ -291,7 +291,7 @@

Influence Trend

}
- +
{{ card.title }}
@if (card.hasData) { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 0e49d8c14..14db47129 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -386,6 +386,11 @@ export class OrgProjectDetailComponent { return; } + const rect = chart.canvas.getBoundingClientRect(); + tip.style.left = `${rect.left + tooltip.caretX + 12}px`; + tip.style.top = `${rect.top + tooltip.caretY}px`; + tip.style.transform = 'translateY(-100%)'; + const title = tooltip.title?.[0] ?? ''; const rows = (tooltip.dataPoints ?? []) .map( From 678194d28c88835569699fa63113618ce7bedc18 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 16:15:05 -0400 Subject: [PATCH 18/80] fix(org-lens): move carousel arrows to section header row Arrows were flanking the card track at mid-height; move them to the far-right of the section title row to match the app-wide pattern used in other card carousels (e.g. Recent Progress on the overview page). Signed-off-by: Daniel Qualls Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 94481ffe2..5cbae2641 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -160,21 +160,21 @@

Our Technical Influence

{{ bandLabel(band) }} } +
+ + +
-
- -
-
- @for (card of technicalCards(); track card.key) { - - } -
+
+
+ @for (card of technicalCards(); track card.key) { + + }
-
@@ -192,21 +192,21 @@

Our Ecosystem Influence

{{ bandLabel(band) }} } +
+ + +
-
- -
-
- @for (card of ecosystemCards(); track card.key) { - - } -
+
+
+ @for (card of ecosystemCards(); track card.key) { + + }
-
From 0fc4f6fc5d3fdcd11a780db16893648285ee4e45 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 16:27:31 -0400 Subject: [PATCH 19/80] feat(org-lens): match card style to app reference and smart arrow visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cards: w-56 → w-72, rounded-xl, small muted title, h-16 sparkline, border-separated stat block with large metric number + short label - Arrows: hide individually based on scroll position; both hidden when all cards fit without scrolling (scrollWidth ≤ clientWidth) - Scroll step updated to 304px (w-72 + gap-4) to advance one full slot - statLabel derived from caption suffix (trimmed) for concise display Signed-off-by: Daniel Qualls Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 69 +++++++++++-------- .../org-project-detail.component.ts | 39 +++++++++-- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 5cbae2641..5ad7dec62 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -160,17 +160,23 @@

Our Technical Influence

{{ bandLabel(band) }} } -
- - -
+ @if (techArrows().left || techArrows().right) { +
+ @if (techArrows().left) { + + } + @if (techArrows().right) { + + } +
+ }
-
+
@for (card of technicalCards(); track card.key) { } @@ -192,17 +198,23 @@

Our Ecosystem Influence

{{ bandLabel(band) }} } -
- - -
+ @if (ecoArrows().left || ecoArrows().right) { +
+ @if (ecoArrows().left) { + + } + @if (ecoArrows().right) { + + } +
+ }
-
+
@for (card of ecosystemCards(); track card.key) { } @@ -283,27 +295,26 @@

Influence Trend

}
- + -
+
@if (card.scopeLabel) { -
{{ card.scopeLabel }}
+
{{ card.scopeLabel }}
} -
- +
-
{{ card.title }}
-
+

{{ card.title }}

+
@if (card.hasData) { } @else {
No data
}
-

- {{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }} -

+
+

{{ card.caption.emphasis }}

+

{{ card.statLabel }}

+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 14db47129..8c529bfca 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -3,8 +3,8 @@ // Generated with [Claude Code](https://claude.ai/code) -import { NgTemplateOutlet } from '@angular/common'; -import { Component, computed, inject, signal, type Signal } from '@angular/core'; +import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common'; +import { Component, computed, effect, ElementRef, inject, PLATFORM_ID, signal, type Signal, ViewChild } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { AccountContextService } from '@services/account-context.service'; @@ -114,11 +114,17 @@ export class OrgProjectDetailComponent { private readonly detailService = inject(OrgLensProjectDetailService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly platformId = inject(PLATFORM_ID); + + @ViewChild('technicalTrack') private techTrackRef?: ElementRef; + @ViewChild('ecosystemTrack') private ecoTrackRef?: ElementRef; protected readonly retryTrigger = signal(0); protected readonly fetchLoading = signal(true); protected readonly fetchError = signal(false); protected readonly detail = signal(null); + protected readonly techArrows = signal({ left: false, right: false }); + protected readonly ecoArrows = signal({ left: false, right: false }); protected readonly tabs: { id: OrgLensProjectDetailTab; label: string; icon: string }[] = [ { id: 'pd-influence', label: 'Our Influence', icon: 'fa-light fa-chart-network' }, @@ -181,6 +187,17 @@ export class OrgProjectDetailComponent { protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); protected readonly ecosystemBoard = computed(() => this.buildBoard('ecosystem', this.ecoSearch())); + // After cards populate (async data load), check whether each track is actually scrollable. + private readonly _scrollEffect = effect(() => { + const techLen = this.technicalCards().length; + const ecoLen = this.ecosystemCards().length; + if (techLen === 0 && ecoLen === 0) return; + Promise.resolve().then(() => { + this.refreshArrows(this.techTrackRef?.nativeElement, true); + this.refreshArrows(this.ecoTrackRef?.nativeElement, false); + }); + }); + // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); @@ -352,9 +369,22 @@ export class OrgProjectDetailComponent { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); } - /** Scrolls a card track by one card slot (240 px = w-56 + gap-4). */ + /** Scrolls a card track by one card slot (304 px = w-72 + gap-4). */ protected scrollCards(el: HTMLElement, direction: 1 | -1): void { - el.scrollBy({ left: direction * 240, behavior: 'smooth' }); + el.scrollBy({ left: direction * 304, behavior: 'smooth' }); + } + + /** Updates left/right arrow visibility from the track's scroll position. */ + protected onTrackScroll(el: HTMLElement, track: 'tech' | 'eco'): void { + this.refreshArrows(el, track === 'tech'); + } + + private refreshArrows(el: HTMLElement | undefined, tech: boolean): void { + if (!el || !isPlatformBrowser(this.platformId)) return; + (tech ? this.techArrows : this.ecoArrows).set({ + left: el.scrollLeft > 0, + right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1, + }); } private initialsFor(name: string): string { @@ -429,6 +459,7 @@ export class OrgProjectDetailComponent { hasData: card.sparkline.length > 0, chartData: this.cardChartData(card.sparkline, card.projectSparkline, colorHex), caption: card.caption, + statLabel: card.caption.suffix.trim().replace(/\.$/, ''), testId: `project-detail-${group}-card-${card.key}`, }; } From 26f9671a356ddc1bae7519f841d7b1830191d390 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 17:11:29 -0400 Subject: [PATCH 20/80] feat(org-lens): vary chart types per metric and fix sparkline options propagation Use area/bar/line chart variants per metric key (pull-requests and meeting-attendance as bars; avg-time-to-merge and board-members as plain lines; all others filled area) to match production. Fix sparkline options not applying by moving lineCardOptions/barCardOptions to stable class-level properties instead of per-card context, sidestepping an Angular 20 zoneless signal-input propagation issue with object properties via ng-template context. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 4 +- .../org-project-detail.component.ts | 134 +++++++++++------- 2 files changed, 83 insertions(+), 55 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 5ad7dec62..b427aed7e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -297,7 +297,7 @@

Influence Trend

-
+
@if (card.scopeLabel) {
{{ card.scopeLabel }}
} @@ -306,7 +306,7 @@

Influence Trend

{{ card.title }}

@if (card.hasData) { - + } @else {
No data
} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 8c529bfca..903fd3d5e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -13,7 +13,7 @@ import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { TagComponent } from '@components/tag/tag.component'; -import { BASE_LINE_CHART_OPTIONS, lfxColors } from '@lfx-one/shared/constants'; +import { lfxColors } from '@lfx-one/shared/constants'; import type { OrgLensLeaderboardMetric, OrgLensProjectBand, @@ -162,9 +162,8 @@ export class OrgProjectDetailComponent { return scores ? bandForScore(scores.ecosystem) : null; }); - // Our Influence tab — Technical + Ecosystem cards (trendline + sentence), same card style. + // Our Influence tab — Technical + Ecosystem cards (per-card chart type and data). private readonly monthLabels: string[] = this.buildMonthLabels(); - protected readonly cardChartOptions: ChartOptions = this.buildCardChartOptions(); protected readonly technicalCards = computed(() => (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')) ); @@ -177,6 +176,10 @@ export class OrgProjectDetailComponent { protected readonly trendChartData = computed>(() => this.buildTrendData()); protected readonly trendChartOptions: ChartOptions = this.buildTrendOptions(); + // Sparkline card options — stable class-level references so Angular passes them correctly through ng-template context. + protected readonly lineCardOptions: ChartOptions = this.buildLineAreaCardOptions(); + protected readonly barCardOptions: ChartOptions = this.buildBarCardOptions(); + // Leaderboards tab — URL-persisted metric toggle + two side-by-side dimension boards with search. protected readonly metricOptions = METRIC_OPTIONS; protected readonly metric = computed(() => this.initMetric()); @@ -369,9 +372,9 @@ export class OrgProjectDetailComponent { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); } - /** Scrolls a card track by one card slot (304 px = w-72 + gap-4). */ + /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ protected scrollCards(el: HTMLElement, direction: 1 | -1): void { - el.scrollBy({ left: direction * 304, behavior: 'smooth' }); + el.scrollBy({ left: direction * 336, behavior: 'smooth' }); } /** Updates left/right arrow visibility from the track's scroll position. */ @@ -394,53 +397,70 @@ export class OrgProjectDetailComponent { return (parts[0][0] + parts[1][0]).toUpperCase(); } - /** - * Card sparkline options: uses an external HTML tooltip (data-lfx-tip) so the popup - * is not constrained by the canvas height and can render at a comfortable reading size. - */ - private buildCardChartOptions(): ChartOptions { + /** Shared external-tooltip callback: positions a fixed DOM overlay near the cursor. */ + private buildExternalTooltipFn(): (args: { chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; tooltip: { opacity: number; caretX: number; caretY: number; title?: string[]; dataPoints?: { dataset: { borderColor: string; backgroundColor: string; label?: string }; formattedValue: string }[] } }) => void { + return ({ chart, tooltip }) => { + const tip = chart.canvas + .closest('[data-sparkline-host]') + ?.querySelector('[data-lfx-tip]'); + if (!tip) return; + + if (tooltip.opacity === 0) { + tip.style.display = 'none'; + return; + } + + const rect = chart.canvas.getBoundingClientRect(); + tip.style.left = `${rect.left + tooltip.caretX + 12}px`; + tip.style.top = `${rect.top + tooltip.caretY}px`; + tip.style.transform = 'translateY(-100%)'; + + const title = tooltip.title?.[0] ?? ''; + const rows = (tooltip.dataPoints ?? []) + .map( + (p) => + '
' + + `${p.dataset.label ?? ''}: ` + + `${p.formattedValue}` + + '
' + ) + .join(''); + + tip.innerHTML = `

${title}

${rows}`; + tip.style.display = 'block'; + }; + } + + private buildLineAreaCardOptions(): ChartOptions { + const external = this.buildExternalTooltipFn() as NonNullable['plugins']>['tooltip']>['external']; return { - ...BASE_LINE_CHART_OPTIONS, - plugins: { - legend: { display: false }, - tooltip: { - enabled: false, - external: ({ chart, tooltip }) => { - const tip = chart.canvas - .closest('[data-sparkline-host]') - ?.querySelector('[data-lfx-tip]'); - if (!tip) return; - - if (tooltip.opacity === 0) { - tip.style.display = 'none'; - return; - } - - const rect = chart.canvas.getBoundingClientRect(); - tip.style.left = `${rect.left + tooltip.caretX + 12}px`; - tip.style.top = `${rect.top + tooltip.caretY}px`; - tip.style.transform = 'translateY(-100%)'; - - const title = tooltip.title?.[0] ?? ''; - const rows = (tooltip.dataPoints ?? []) - .map( - (p) => - '
' + - `` + - `${p.dataset.label ?? ''}: ` + - `${p.formattedValue}` + - '
' - ) - .join(''); - - tip.innerHTML = `

${title}

${rows}`; - tip.style.display = 'block'; - }, - }, - }, + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { display: false }, tooltip: { enabled: false, external } }, + scales: { x: { display: false }, y: { display: false } }, }; } + private buildBarCardOptions(): ChartOptions { + const external = this.buildExternalTooltipFn() as NonNullable['plugins']>['tooltip']>['external']; + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { display: false }, tooltip: { enabled: false, external } }, + scales: { x: { display: false }, y: { display: false, beginAtZero: true } }, + }; + } + + /** Maps a card key to its preferred visualization variant. */ + private chartVariantFor(key: string): 'area' | 'bar' | 'line' { + if (['pull-requests', 'meeting-attendance', 'event-attendance'].includes(key)) return 'bar'; + if (['avg-time-to-merge', 'board-members'].includes(key)) return 'line'; + return 'area'; + } + /** Twelve trailing short-month labels (oldest → newest) for sparkline + trend tooltips. */ private buildMonthLabels(): string[] { const out: string[] = []; @@ -452,27 +472,35 @@ export class OrgProjectDetailComponent { } private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem') { + const variant = this.chartVariantFor(card.key); return { key: card.key, title: card.label, scopeLabel: card.scopeLabel, hasData: card.sparkline.length > 0, - chartData: this.cardChartData(card.sparkline, card.projectSparkline, colorHex), + chartType: (variant === 'bar' ? 'bar' : 'line') as ChartType, + chartData: this.buildCardChartData(card.sparkline, card.projectSparkline, colorHex, variant), caption: card.caption, statLabel: card.caption.suffix.trim().replace(/\.$/, ''), testId: `project-detail-${group}-card-${card.key}`, }; } - /** Dual-line card sparkline: the org metric line in `colorHex` plus a grey project-average reference. */ - private cardChartData(series: number[], projectSeries: number[], colorHex: string): ChartData { + private buildCardChartData(series: number[], projectSeries: number[], colorHex: string, variant: 'area' | 'bar' | 'line'): ChartData { + if (variant === 'bar') { + return { + labels: this.monthLabels, + datasets: [{ label: 'Your company', data: series, backgroundColor: colorHex + '99', borderColor: colorHex, borderWidth: 0, borderRadius: 4 }], + }; + } + const fill = variant === 'area'; const datasets: ChartData['datasets'] = [ { label: 'Your company', data: series, borderColor: colorHex, - backgroundColor: 'transparent', - fill: false, + backgroundColor: fill ? colorHex + '33' : 'transparent', + fill: fill ? 'origin' : false, tension: 0.4, borderWidth: 2, pointRadius: 0, From 6aff8bb8a9096ae9f6bd74e7a16021d94f102dfd Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 23 Jun 2026 17:15:42 -0400 Subject: [PATCH 21/80] fix(org-lens): fix member ordering and nested ternary lint errors Move @ViewChild decorated fields before inject() fields to satisfy member-ordering (decorated-field before field). Move scrollCards and onTrackScroll protected methods before the first private method block. Replace nested ternary for logoUrl in demo-data with a lookup map. Signed-off-by: daniel qualls --- .../org-project-detail.component.ts | 26 +++++++++---------- .../org-lens-project-detail.demo-data.ts | 12 +++++---- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 903fd3d5e..33accd6e4 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -110,15 +110,15 @@ function bandForScore(score: number): OrgLensProjectBand { templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { + @ViewChild('technicalTrack') private techTrackRef?: ElementRef; + @ViewChild('ecosystemTrack') private ecoTrackRef?: ElementRef; + protected readonly accountContext = inject(AccountContextService); private readonly detailService = inject(OrgLensProjectDetailService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly platformId = inject(PLATFORM_ID); - @ViewChild('technicalTrack') private techTrackRef?: ElementRef; - @ViewChild('ecosystemTrack') private ecoTrackRef?: ElementRef; - protected readonly retryTrigger = signal(0); protected readonly fetchLoading = signal(true); protected readonly fetchError = signal(false); @@ -271,6 +271,16 @@ export class OrgProjectDetailComponent { })); } + /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ + protected scrollCards(el: HTMLElement, direction: 1 | -1): void { + el.scrollBy({ left: direction * 336, behavior: 'smooth' }); + } + + /** Updates left/right arrow visibility from the track's scroll position. */ + protected onTrackScroll(el: HTMLElement, track: 'tech' | 'eco'): void { + this.refreshArrows(el, track === 'tech'); + } + private initMetric(): OrgLensLeaderboardMetric { const raw = this.queryParamMap().get('metric'); return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; @@ -372,16 +382,6 @@ export class OrgProjectDetailComponent { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value); } - /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ - protected scrollCards(el: HTMLElement, direction: 1 | -1): void { - el.scrollBy({ left: direction * 336, behavior: 'smooth' }); - } - - /** Updates left/right arrow visibility from the track's scroll position. */ - protected onTrackScroll(el: HTMLElement, track: 'tech' | 'eco'): void { - this.refreshArrows(el, track === 'tech'); - } - private refreshArrows(el: HTMLElement | undefined, tech: boolean): void { if (!el || !isPlatformBrowser(this.platformId)) return; (tech ? this.techArrows : this.ecoArrows).set({ diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 20ef2a2ce..37721bed1 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -471,6 +471,12 @@ function deltaFromSparkline(series: number[]): number { return start === 0 ? 0 : Math.round(((series[series.length - 1] - start) / start) * 100) / 100; } +const CNCF_ARTWORK = 'https://raw.githubusercontent.com/cncf/artwork/master/projects'; +const PROJECT_LOGO_URLS: Record = { + kubernetes: `${CNCF_ARTWORK}/kubernetes/icon/color/kubernetes-icon-color.svg`, + argo: `${CNCF_ARTWORK}/argo/icon/color/argo-icon-color.svg`, +}; + /** * Demo detail for one project. Returns `null` for an unknown slug so the page renders its * not-found (404) state. `orgUid` echoes into the response envelope; `orgName` personalizes @@ -486,11 +492,7 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu hero: { projectName: seed.name, description: seed.description, - logoUrl: projectSlug === 'kubernetes' - ? 'https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg' - : projectSlug === 'argo' - ? 'https://raw.githubusercontent.com/cncf/artwork/master/projects/argo/icon/color/argo-icon-color.svg' - : '', + logoUrl: PROJECT_LOGO_URLS[projectSlug] ?? '', sourceUrl: seed.sourceUrl, sourceLabel: `${seed.name} - ${seed.description.replace(/\.$/, '')}`, lfxInsightsUrl: `https://insights.linuxfoundation.org/project/${projectSlug}`, From 87d7229e8e93e70720d7c368c061bef9d7a4c4c2 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 11:19:08 -0400 Subject: [PATCH 22/80] style(org-lens): match carousel arrow style to app.lfx.dev production Replace conditional rounded-full h-7 w-7 arrows with always-visible h-8 w-8 rounded square buttons matching the exact production class set: border-gray-300 bg-white text-gray-600 hover:bg-gray-100 hover:border-gray-400. Both < and > arrows now always render regardless of scroll position. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index b427aed7e..201851eae 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -160,20 +160,14 @@

Our Technical Influence

{{ bandLabel(band) }} } - @if (techArrows().left || techArrows().right) { -
- @if (techArrows().left) { - - } - @if (techArrows().right) { - - } -
- } +
+ + +
@@ -198,20 +192,14 @@

Our Ecosystem Influence

{{ bandLabel(band) }} } - @if (ecoArrows().left || ecoArrows().right) { -
- @if (ecoArrows().left) { - - } - @if (ecoArrows().right) { - - } -
- } +
+ + +
From bebe965662f7dfe8f70ac12eba71b37d893657d2 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 11:28:33 -0400 Subject: [PATCH 23/80] fix(org-lens): fix sparkline tooltip HTML and remove hover ring markers Closes a missing closing quote in the inline-style string generated by buildExternalTooltipFn that caused the entire subsequent HTML to be swallowed as a CSS attribute value, producing garbled tooltip content. Also sets pointHoverRadius to 0 on both sparkline datasets so Chart.js does not draw hollow ring markers on hover. Signed-off-by: daniel qualls --- .../org-project-detail.component.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 33accd6e4..220412543 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -419,15 +419,15 @@ export class OrgProjectDetailComponent { const rows = (tooltip.dataPoints ?? []) .map( (p) => - '
' + - `${p.dataset.label ?? ''}: ` + + '
' + + `` + + `${p.dataset.label ?? ''}: ` + `${p.formattedValue}` + '
' ) .join(''); - tip.innerHTML = `

${title}

${rows}`; + tip.innerHTML = `

${title}

${rows}`; tip.style.display = 'block'; }; } @@ -504,7 +504,7 @@ export class OrgProjectDetailComponent { tension: 0.4, borderWidth: 2, pointRadius: 0, - pointHoverRadius: 4, + pointHoverRadius: 0, }, ]; if (projectSeries.length > 0) { @@ -517,7 +517,7 @@ export class OrgProjectDetailComponent { tension: 0.4, borderWidth: 1.5, pointRadius: 0, - pointHoverRadius: 4, + pointHoverRadius: 0, }); } return { labels: this.monthLabels, datasets }; From 4a213a587adfba720b20bfdbae931e1933b8569a Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 11:30:51 -0400 Subject: [PATCH 24/80] style(org-lens): pill tabs and section header divider lines on project detail Replace underline-style tab buttons with rounded pill container matching app.lfx.dev convention. Add a flex-1 h-px divider line in all three section headers (Technical, Ecosystem, Influence Trend) so the title extends to the right edge per the app-wide section heading pattern. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 201851eae..de77738ce 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -113,7 +113,7 @@

@for (tab of tabs; track tab.id) { @@ -124,12 +124,11 @@

@@ -160,7 +159,8 @@

Our Technical Influence

{{ bandLabel(band) }}
} -
+
+
@@ -192,7 +192,8 @@

Our Ecosystem Influence

{{ bandLabel(band) }} } -
+
+
@@ -264,7 +265,10 @@

Our Ecosystem Influence

-

Influence Trend

+
+

Influence Trend

+
+
@if (hasTrendHistory()) {
From aa067c76dbed544d43b04d403b00c8677c943583 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 11:52:06 -0400 Subject: [PATCH 25/80] feat(org-lens): card detail drawer with trend chart and monthly breakdown Clicking any influence card opens a right-side p-drawer showing the full 12-month trend chart (axes + legend), a primary stat summary, and a monthly breakdown table with Your org vs Project average and month-over- month delta indicators. Introduces InfluenceCardVm (module-local) and OrgLensInfluenceCardDetailRow (shared interface), per-card detailChartOptions with visible axes, and fixes the avg-merge-time chart variant key mismatch. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 98 +++++++++++++++- .../org-project-detail.component.ts | 108 ++++++++++++++++-- .../org-lens-project-detail.interface.ts | 10 ++ 3 files changed, 202 insertions(+), 14 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index de77738ce..e0f6d6f2c 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -113,7 +113,7 @@

@for (tab of tabs; track tab.id) { @@ -131,7 +131,6 @@

- {{ tab.label }} } @@ -289,7 +288,12 @@

Influence Trend

-
+
-
+ @@ -390,3 +394,87 @@

Influence Trend

}
+ + + + @if (selectedCard(); as card) { + +
+
+ +
+
+

{{ card.title }}

+

Last 12 months · {{ card.scopeLabel ?? 'All projects' }}

+
+
+
+ +
+ +
+

{{ card.caption.emphasis }}

+

{{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }}

+
+ + +
+

12-Month Trend

+
+ @if (card.hasData) { + + } @else { +
No data available
+ } +
+
+ + +
+

Monthly Breakdown

+
+ + + + + + + + + + + @for (row of cardDetailRows(); track row.month) { + + + + + + + } + +
MonthYour orgProject avgvs prior month
{{ row.month }}{{ row.org }}{{ card.valueSuffix }}{{ row.avg }}{{ card.valueSuffix }} + @if (row.delta > 0) { + + + +{{ row.delta }}{{ card.valueSuffix }} + + } @else if (row.delta < 0) { + + + {{ row.delta }}{{ card.valueSuffix }} + + } @else { + + } +
+
+
+
+ } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 220412543..b7bb3dfb6 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -15,6 +15,7 @@ import { EmptyStateComponent } from '@components/empty-state/empty-state.compone import { TagComponent } from '@components/tag/tag.component'; import { lfxColors } from '@lfx-one/shared/constants'; import type { + OrgLensInfluenceCardDetailRow, OrgLensLeaderboardMetric, OrgLensProjectBand, OrgLensProjectDetailPageState, @@ -26,9 +27,26 @@ import type { } from '@lfx-one/shared/interfaces'; import { parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; +import { DrawerModule } from 'primeng/drawer'; import type { ChartData, ChartOptions, ChartType } from 'chart.js'; import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; +/** Presentation VM for each influence card — includes chart objects typed for Chart.js. */ +interface InfluenceCardVm { + key: string; + title: string; + scopeLabel: string | null; + hasData: boolean; + chartType: ChartType; + chartData: ChartData; + chartOptions: ChartOptions; + detailChartOptions: ChartOptions; + valueSuffix: string; + caption: { prefix: string; emphasis: string; suffix: string }; + statLabel: string; + testId: string; +} + const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; const VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); @@ -106,7 +124,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent, DrawerModule], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -125,6 +143,22 @@ export class OrgProjectDetailComponent { protected readonly detail = signal(null); protected readonly techArrows = signal({ left: false, right: false }); protected readonly ecoArrows = signal({ left: false, right: false }); + protected readonly selectedCard = signal(null); + protected readonly drawerOpen = signal(false); + + protected readonly cardDetailRows = computed(() => { + const card = this.selectedCard(); + if (!card) return []; + const ds = (card.chartData as { datasets: { data: number[] }[] }).datasets; + const orgVals: number[] = ds[0]?.data ?? []; + const avgVals: number[] = ds[1]?.data ?? []; + return this.monthLabels.map((month, i) => ({ + month, + org: orgVals[i] ?? 0, + avg: avgVals[i] ?? 0, + delta: i > 0 ? (orgVals[i] ?? 0) - (orgVals[i - 1] ?? 0) : 0, + })); + }); protected readonly tabs: { id: OrgLensProjectDetailTab; label: string; icon: string }[] = [ { id: 'pd-influence', label: 'Our Influence', icon: 'fa-light fa-chart-network' }, @@ -271,6 +305,19 @@ export class OrgProjectDetailComponent { })); } + protected openCardDetail(card: InfluenceCardVm): void { + this.selectedCard.set(card); + this.drawerOpen.set(true); + } + + protected closeCardDetail(): void { + this.drawerOpen.set(false); + } + + protected formatDetailValue(val: number, suffix: string): string { + return `${val}${suffix}`; + } + /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ protected scrollCards(el: HTMLElement, direction: 1 | -1): void { el.scrollBy({ left: direction * 336, behavior: 'smooth' }); @@ -398,7 +445,7 @@ export class OrgProjectDetailComponent { } /** Shared external-tooltip callback: positions a fixed DOM overlay near the cursor. */ - private buildExternalTooltipFn(): (args: { chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; tooltip: { opacity: number; caretX: number; caretY: number; title?: string[]; dataPoints?: { dataset: { borderColor: string; backgroundColor: string; label?: string }; formattedValue: string }[] } }) => void { + private buildExternalTooltipFn(valueSuffix = ''): (args: { chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; tooltip: { opacity: number; caretX: number; caretY: number; title?: string[]; dataPoints?: { dataset: { borderColor: string; backgroundColor: string; label?: string }; formattedValue: string }[] } }) => void { return ({ chart, tooltip }) => { const tip = chart.canvas .closest('[data-sparkline-host]') @@ -422,7 +469,7 @@ export class OrgProjectDetailComponent { '
' + `` + `${p.dataset.label ?? ''}: ` + - `${p.formattedValue}` + + `${p.formattedValue}${valueSuffix}` + '
' ) .join(''); @@ -432,8 +479,8 @@ export class OrgProjectDetailComponent { }; } - private buildLineAreaCardOptions(): ChartOptions { - const external = this.buildExternalTooltipFn() as NonNullable['plugins']>['tooltip']>['external']; + private buildLineAreaCardOptions(valueSuffix = ''): ChartOptions { + const external = this.buildExternalTooltipFn(valueSuffix) as NonNullable['plugins']>['tooltip']>['external']; return { responsive: true, maintainAspectRatio: false, @@ -443,8 +490,8 @@ export class OrgProjectDetailComponent { }; } - private buildBarCardOptions(): ChartOptions { - const external = this.buildExternalTooltipFn() as NonNullable['plugins']>['tooltip']>['external']; + private buildBarCardOptions(valueSuffix = ''): ChartOptions { + const external = this.buildExternalTooltipFn(valueSuffix) as NonNullable['plugins']>['tooltip']>['external']; return { responsive: true, maintainAspectRatio: false, @@ -454,10 +501,49 @@ export class OrgProjectDetailComponent { }; } + private buildDetailChartOptions(valueSuffix = '', variant: 'area' | 'bar' | 'line' = 'area'): ChartOptions { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + display: true, + position: 'bottom', + labels: { usePointStyle: true, pointStyleWidth: 8, font: { size: 11 }, color: '#6B7280' }, + }, + tooltip: { + callbacks: { + label: (ctx: { dataset: { label?: string }; formattedValue: string }) => + ` ${ctx.dataset.label ?? ''}: ${ctx.formattedValue}${valueSuffix}`, + }, + }, + }, + scales: { + x: { + display: true, + grid: { display: false }, + ticks: { font: { size: 10 }, color: '#9CA3AF', maxRotation: 0, maxTicksLimit: 6 }, + }, + y: { + display: true, + position: 'right', + grid: { color: '#F3F4F6' }, + ticks: { + font: { size: 10 }, + color: '#9CA3AF', + maxTicksLimit: 5, + }, + beginAtZero: variant === 'bar', + }, + }, + }; + } + /** Maps a card key to its preferred visualization variant. */ private chartVariantFor(key: string): 'area' | 'bar' | 'line' { if (['pull-requests', 'meeting-attendance', 'event-attendance'].includes(key)) return 'bar'; - if (['avg-time-to-merge', 'board-members'].includes(key)) return 'line'; + if (['avg-merge-time', 'board-members'].includes(key)) return 'line'; return 'area'; } @@ -471,8 +557,9 @@ export class OrgProjectDetailComponent { return out; } - private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem') { + private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem'): InfluenceCardVm { const variant = this.chartVariantFor(card.key); + const valueSuffix = card.key === 'avg-merge-time' ? ' days' : ''; return { key: card.key, title: card.label, @@ -480,6 +567,9 @@ export class OrgProjectDetailComponent { hasData: card.sparkline.length > 0, chartType: (variant === 'bar' ? 'bar' : 'line') as ChartType, chartData: this.buildCardChartData(card.sparkline, card.projectSparkline, colorHex, variant), + chartOptions: variant === 'bar' ? this.buildBarCardOptions(valueSuffix) : this.buildLineAreaCardOptions(valueSuffix), + detailChartOptions: this.buildDetailChartOptions(valueSuffix, variant), + valueSuffix, caption: card.caption, statLabel: card.caption.suffix.trim().replace(/\.$/, ''), testId: `project-detail-${group}-card-${card.key}`, diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index b37740ce9..c51343d8b 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -65,6 +65,16 @@ export interface OrgLensProjectInfluenceCard { caption: { prefix: string; emphasis: string; suffix: string }; } +/** One row in the monthly breakdown table shown in the card detail drawer. */ +export interface OrgLensInfluenceCardDetailRow { + month: string; + org: number; + avg: number; + /** Delta of org value vs the previous month (positive = up, negative = down, 0 = flat). */ + delta: number; +} + + /** One monthly point on the Influence Trend chart. */ export interface OrgLensProjectTrendPoint { /** Year-month bin, e.g. "2025-07". */ From 47a21a8331a03f2ee976b1b97a4dc5308d2f38cd Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 13:38:44 -0400 Subject: [PATCH 26/80] feat(org-lens): replace drawer chart with card-specific detail tables LFXV2-1885 Remove the repeated 12-month chart and generic monthly breakdown table from the influence card detail drawer. Replace with a definition table (Definition / Total count for this project / Data source) and a card-specific data table whose columns match app.lfx.dev per-card conventions (Maintainers, Contributors, Commit Activities, Pull Requests, Avg Merge Time, Collaboration Activity, Meeting Attendance, Board Members, Committee Members, plus the remaining ecosystem cards). Add OrgLensCardDetailSection / OrgLensCardDetailCell / OrgLensCardDetailRow / OrgLensCardDefinition to shared interfaces; generate demo rows for all 14 card keys in the demo-data helper; wire via cardDetail computed in the component. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 139 ++++++--- .../org-project-detail.component.ts | 79 ++--- .../org-lens-project-detail.demo-data.ts | 295 +++++++++++++++++- .../org-lens-project-detail.interface.ts | 39 ++- 4 files changed, 433 insertions(+), 119 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index e0f6d6f2c..143c50aa3 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -160,16 +160,27 @@

Our Technical Influence

}
- -
-
+
@for (card of technicalCards(); track card.key) { } @@ -193,23 +204,33 @@

Our Ecosystem Influence

}
- -
-
+
@for (card of ecosystemCards(); track card.key) { }
-
} @case ('pd-leaderboards') { @@ -423,58 +444,72 @@

{{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }}

- -
-

12-Month Trend

-
- @if (card.hasData) { - - } @else { -
No data available
- } -
-
- - -
-

Monthly Breakdown

-
- + @if (cardDetail(); as detail) { + +
+
- - - - + + + - - @for (row of cardDetailRows(); track row.month) { - - - - - - - } + + + + + + + + +
MonthYour orgProject avgvs prior monthDefinition + {{ detail.definition.totalType === 'average' ? 'Average for this project' : 'Total count for this project' }} + Data source
{{ row.month }}{{ row.org }}{{ card.valueSuffix }}{{ row.avg }}{{ card.valueSuffix }} - @if (row.delta > 0) { - - - +{{ row.delta }}{{ card.valueSuffix }} - - } @else if (row.delta < 0) { - - - {{ row.delta }}{{ card.valueSuffix }} - - } @else { - - } -
{{ detail.definition.text }}{{ detail.definition.total }}{{ detail.definition.dataSource }}
Showing 1 of 1 row
-
+ + + @if (detail.rows.length > 0) { +
+ + + + @for (col of detail.columns; track col) { + + } + + + + @for (dataRow of detail.rows; track $index) { + + @for (cell of dataRow.cells; track $index) { + + } + + } + +
{{ col }}
+ @if (cell.person; as person) { +
+ + {{ person.name }} +
+ } @else { + {{ cell.text }} + } +
+
Showing {{ detail.rows.length }} of {{ detail.rows.length }} rows
+
+ } @else { +
No data available for this metric.
+ } + }
} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index b7bb3dfb6..1a5553258 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -15,7 +15,7 @@ import { EmptyStateComponent } from '@components/empty-state/empty-state.compone import { TagComponent } from '@components/tag/tag.component'; import { lfxColors } from '@lfx-one/shared/constants'; import type { - OrgLensInfluenceCardDetailRow, + OrgLensCardDetailSection, OrgLensLeaderboardMetric, OrgLensProjectBand, OrgLensProjectDetailPageState, @@ -40,7 +40,6 @@ interface InfluenceCardVm { chartType: ChartType; chartData: ChartData; chartOptions: ChartOptions; - detailChartOptions: ChartOptions; valueSuffix: string; caption: { prefix: string; emphasis: string; suffix: string }; statLabel: string; @@ -146,18 +145,10 @@ export class OrgProjectDetailComponent { protected readonly selectedCard = signal(null); protected readonly drawerOpen = signal(false); - protected readonly cardDetailRows = computed(() => { + protected readonly cardDetail = computed(() => { const card = this.selectedCard(); - if (!card) return []; - const ds = (card.chartData as { datasets: { data: number[] }[] }).datasets; - const orgVals: number[] = ds[0]?.data ?? []; - const avgVals: number[] = ds[1]?.data ?? []; - return this.monthLabels.map((month, i) => ({ - month, - org: orgVals[i] ?? 0, - avg: avgVals[i] ?? 0, - delta: i > 0 ? (orgVals[i] ?? 0) - (orgVals[i - 1] ?? 0) : 0, - })); + if (!card) return null; + return this.detail()?.cardDetails?.[card.key] ?? null; }); protected readonly tabs: { id: OrgLensProjectDetailTab; label: string; icon: string }[] = [ @@ -314,8 +305,9 @@ export class OrgProjectDetailComponent { this.drawerOpen.set(false); } - protected formatDetailValue(val: number, suffix: string): string { - return `${val}${suffix}`; + /** Returns initials for a person name (used in drawer person-cell avatars). */ + protected personInitials(name: string): string { + return this.initialsFor(name); } /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ @@ -445,11 +437,20 @@ export class OrgProjectDetailComponent { } /** Shared external-tooltip callback: positions a fixed DOM overlay near the cursor. */ - private buildExternalTooltipFn(valueSuffix = ''): (args: { chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; tooltip: { opacity: number; caretX: number; caretY: number; title?: string[]; dataPoints?: { dataset: { borderColor: string; backgroundColor: string; label?: string }; formattedValue: string }[] } }) => void { + private buildExternalTooltipFn( + valueSuffix = '' + ): (args: { + chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; + tooltip: { + opacity: number; + caretX: number; + caretY: number; + title?: string[]; + dataPoints?: { dataset: { borderColor: string; backgroundColor: string; label?: string }; formattedValue: string }[]; + }; + }) => void { return ({ chart, tooltip }) => { - const tip = chart.canvas - .closest('[data-sparkline-host]') - ?.querySelector('[data-lfx-tip]'); + const tip = chart.canvas.closest('[data-sparkline-host]')?.querySelector('[data-lfx-tip]'); if (!tip) return; if (tooltip.opacity === 0) { @@ -501,45 +502,6 @@ export class OrgProjectDetailComponent { }; } - private buildDetailChartOptions(valueSuffix = '', variant: 'area' | 'bar' | 'line' = 'area'): ChartOptions { - return { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: 'index', intersect: false }, - plugins: { - legend: { - display: true, - position: 'bottom', - labels: { usePointStyle: true, pointStyleWidth: 8, font: { size: 11 }, color: '#6B7280' }, - }, - tooltip: { - callbacks: { - label: (ctx: { dataset: { label?: string }; formattedValue: string }) => - ` ${ctx.dataset.label ?? ''}: ${ctx.formattedValue}${valueSuffix}`, - }, - }, - }, - scales: { - x: { - display: true, - grid: { display: false }, - ticks: { font: { size: 10 }, color: '#9CA3AF', maxRotation: 0, maxTicksLimit: 6 }, - }, - y: { - display: true, - position: 'right', - grid: { color: '#F3F4F6' }, - ticks: { - font: { size: 10 }, - color: '#9CA3AF', - maxTicksLimit: 5, - }, - beginAtZero: variant === 'bar', - }, - }, - }; - } - /** Maps a card key to its preferred visualization variant. */ private chartVariantFor(key: string): 'area' | 'bar' | 'line' { if (['pull-requests', 'meeting-attendance', 'event-attendance'].includes(key)) return 'bar'; @@ -568,7 +530,6 @@ export class OrgProjectDetailComponent { chartType: (variant === 'bar' ? 'bar' : 'line') as ChartType, chartData: this.buildCardChartData(card.sparkline, card.projectSparkline, colorHex, variant), chartOptions: variant === 'bar' ? this.buildBarCardOptions(valueSuffix) : this.buildLineAreaCardOptions(valueSuffix), - detailChartOptions: this.buildDetailChartOptions(valueSuffix, variant), valueSuffix, caption: card.caption, statLabel: card.caption.suffix.trim().replace(/\.$/, ''), diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 37721bed1..172550680 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -4,6 +4,9 @@ // Generated with [Claude Code](https://claude.ai/code) import type { + OrgLensCardDetailCell, + OrgLensCardDetailRow, + OrgLensCardDetailSection, OrgLensProjectDetailResponse, OrgLensProjectHealth, OrgLensProjectInfluenceCard, @@ -251,7 +254,11 @@ function shareOf(n: number, total: number): number { return total === 0 ? 0 : n / total; } -interface Caption { prefix: string; emphasis: string; suffix: string } +interface Caption { + prefix: string; + emphasis: string; + suffix: string; +} function card( key: string, @@ -477,6 +484,291 @@ const PROJECT_LOGO_URLS: Record = { argo: `${CNCF_ARTWORK}/argo/icon/color/argo-icon-color.svg`, }; +// --------------------------------------------------------------------------- +// Card detail drawer data — definition table + card-specific data tables +// --------------------------------------------------------------------------- + +/** Demo people names used across all card detail tables. */ +const DEMO_PEOPLE = [ + 'Alex Chen', + 'Jordan Miller', + 'Sam Patel', + 'Casey Thompson', + 'Morgan Davis', + 'Riley Anderson', + 'Taylor Brown', + 'Quinn Wilson', + 'Avery Johnson', + 'Blake Lee', +]; + +const DEMO_COMMITS = [ + 'Fix race condition in scheduler', + 'Add unit tests for auth module', + 'Refactor API error handling', + 'Update dependency versions', + 'Improve performance of query path', +]; + +const DEMO_PRS = [ + 'feat: add pod disruption budget support', + 'fix: race condition in scheduler', + 'docs: update API reference', + 'refactor: improve error handling', + 'feat: implement resource quotas', +]; + +/** Month+year dates (static for SSR stability). */ +const MONTH_DATES = ['Mar 2023', 'Jun 2024', 'Sep 2024', 'Nov 2024', 'Jan 2025', 'Apr 2025']; + +/** Full dates (static for SSR stability). */ +const FULL_DATES = ['Mar 15, 2024', 'Jun 22, 2024', 'Sep 8, 2024', 'Nov 30, 2024', 'Jan 14, 2025', 'Apr 5, 2025']; + +const DEMO_COMMITTEES = ['Technical Oversight Committee', 'Security TAG', 'Storage TAG', 'App Delivery TAG', 'Runtime TAG']; + +function personCell(i: number, pool: string[] = DEMO_PEOPLE): OrgLensCardDetailCell { + return { person: { name: pool[i % pool.length] } }; +} + +function textCell(value: string): OrgLensCardDetailCell { + return { text: value }; +} + +function row(...cells: OrgLensCardDetailCell[]): OrgLensCardDetailRow { + return { cells }; +} + +/** Generates drawer detail data for all influence cards in a project. */ +function buildCardDetails(seed: ProjectDetailSeed): Record { + const { org, totals } = seed; + const eco = seed.ecosystem; + const repoGroup = seed.name.split(' ')[0].toLowerCase(); + + return { + maintainers: { + definition: { + text: 'Individuals granted maintainer status with merge rights and code ownership for this project.', + totalType: 'count', + total: totals.maintainers.toString(), + dataSource: 'LFX Insights', + }, + columns: ['Our Contributors', 'Username', 'Granted Maintainer Status'], + rows: Array.from({ length: Math.min(org.maintainers, 5) }, (_, i) => + row(personCell(i), textCell('@' + DEMO_PEOPLE[i % DEMO_PEOPLE.length].toLowerCase().replace(' ', '.')), textCell('MAINTAINERS.md')) + ), + }, + + contributors: { + definition: { + text: 'Individuals who made at least one contribution (commit, PR, review, or comment) in the selected time range.', + totalType: 'count', + total: totals.contributors.toLocaleString(), + dataSource: 'LFX Insights', + }, + columns: ['Our Contributors', 'Username', 'First activity', 'Most recent', '# Contributions'], + rows: Array.from({ length: Math.min(org.contributors, 5) }, (_, i) => + row( + personCell(i), + textCell('@' + DEMO_PEOPLE[i % DEMO_PEOPLE.length].toLowerCase().replace(' ', '.')), + textCell(MONTH_DATES[i % MONTH_DATES.length]), + textCell(MONTH_DATES[(i + 2) % MONTH_DATES.length]), + textCell(String(Math.max(1, Math.round(org.commits / Math.max(1, org.contributors)) + i * 3))) + ) + ), + }, + + commits: { + definition: { + text: "Code contributions committed directly to this project's repositories.", + totalType: 'count', + total: totals.commits.toLocaleString(), + dataSource: 'LFX Insights', + }, + columns: ['Repository Group', 'Committer', 'Date', 'Commit'], + rows: + org.commits === 0 + ? [] + : DEMO_COMMITS.slice(0, 5).map((commit, i) => row(textCell(repoGroup), personCell(i), textCell(FULL_DATES[i % FULL_DATES.length]), textCell(commit))), + }, + + 'pull-requests': { + definition: { + text: "Pull requests opened against this project's repositories in the selected time range.", + totalType: 'count', + total: totals.prs.toLocaleString(), + dataSource: 'LFX Insights', + }, + columns: ['Repository Group', 'Committer', 'Date', 'PR Opened'], + rows: + org.prs === 0 + ? [] + : DEMO_PRS.slice(0, 5).map((pr, i) => row(textCell(repoGroup), personCell(i), textCell(FULL_DATES[i % FULL_DATES.length]), textCell(pr))), + }, + + 'avg-merge-time': { + definition: { + text: "Average time from when a pull request is opened to when it is merged, for your organization's contributors.", + totalType: 'average', + total: '48.3 days', + dataSource: 'LFX Insights', + }, + columns: ['Repo', 'Our Contributors', 'PR Name', 'Date', 'Merge Time'], + rows: + org.prs === 0 + ? [] + : DEMO_PRS.slice(0, 5).map((pr, i) => + row( + textCell(repoGroup), + personCell(i), + textCell(pr), + textCell(FULL_DATES[i % FULL_DATES.length]), + textCell(String(Math.round(40 + i * 3)) + ' days') + ) + ), + }, + + collaboration: { + definition: { + text: 'Interactions across collaboration platforms including Slack, mailing lists, GitHub Issues, Jira, and community forums.', + totalType: 'count', + total: '15,333', + dataSource: 'Confluence / Jira / GitHub / GitLab / Groups.io / Slack', + }, + columns: ['Source', 'Our Collaborators', 'Location', 'Count', 'Most recent'], + rows: + eco.collaboration === 0 + ? [] + : ['GitHub', 'Slack', 'Groups.io', 'Jira'].map((source, i) => + row( + textCell(source), + personCell(i), + textCell(['Issues & PRs', '#general channel', 'dev mailing list', 'Project board'][i]), + textCell(String(Math.max(1, Math.round(eco.collaboration * (0.35 - i * 0.06))))), + textCell(FULL_DATES[(i + 1) % FULL_DATES.length]) + ) + ), + }, + + 'meeting-attendance': { + definition: { + text: 'Attendance at project committee, working group, and community meetings.', + totalType: 'count', + total: String(Math.max(1, Math.round(eco.meetingAttendance / 0.115))), + dataSource: 'LFX', + }, + columns: ['Our meeting attendees', 'Meeting type', 'Meeting date'], + rows: + eco.meetingAttendance === 0 + ? [] + : ['Contributor Meeting', 'Technical Steering Committee', 'Community Call'] + .slice(0, Math.min(eco.meetingAttendance, 3)) + .map((type, i) => row(personCell(i), textCell(type), textCell(FULL_DATES[(i + 2) % FULL_DATES.length]))), + }, + + 'board-members': { + definition: { + text: "Seat on the governing board of the project's foundation.", + totalType: 'count', + total: '55', + dataSource: 'LFX', + }, + columns: ['Our board members', 'Added to board', 'Granted seat by'], + rows: Array.from({ length: Math.min(eco.boardMembers, 5) }, (_, i) => + row( + personCell(i, ['Bridget Cromwell', 'Alexander Levan', 'Morgan Thompson', 'Casey Williams', 'Jordan Park']), + textCell(MONTH_DATES[i % MONTH_DATES.length]), + textCell('Membership Entitlement') + ) + ), + }, + + 'committee-members': { + definition: { + text: 'Individual who is on a foundation committee, such as advisory groups, steering committees, and marketing committees.', + totalType: 'count', + total: '1,764', + dataSource: 'LFX', + }, + columns: ['Our committee members', 'Committee', 'Date joined'], + rows: Array.from({ length: Math.min(eco.committeeMembers, 5) }, (_, i) => + row(personCell(i), textCell(DEMO_COMMITTEES[i % DEMO_COMMITTEES.length]), textCell(FULL_DATES[i % FULL_DATES.length])) + ), + }, + + 'event-attendance': { + definition: { + text: "Registration and attendance at events hosted or co-located with this project's foundation.", + totalType: 'count', + total: '2,840', + dataSource: 'LFX', + }, + columns: ['Our attendees', 'Event name', 'Date', 'Location'], + rows: [ + row(personCell(0), textCell('KubeCon EU 2025'), textCell('Mar 2025'), textCell('London, UK')), + row(personCell(1), textCell('KubeCon NA 2024'), textCell('Nov 2024'), textCell('Salt Lake City, UT')), + row(personCell(2), textCell('CloudNativeCon NA 2024'), textCell('Nov 2024'), textCell('Salt Lake City, UT')), + ], + }, + + 'event-speakers': { + definition: { + text: 'Employees who presented talks, workshops, or keynotes at foundation-hosted events.', + totalType: 'count', + total: '184', + dataSource: 'LFX', + }, + columns: ['Our speakers', 'Event name', 'Talk title', 'Date'], + rows: [ + row(personCell(0), textCell('KubeCon EU 2025'), textCell('Building Secure Software Supply Chains'), textCell('Mar 2025')), + row(personCell(1), textCell('KubeCon NA 2024'), textCell('Scaling Multi-Cluster Deployments'), textCell('Nov 2024')), + ], + }, + + 'event-sponsorships': { + definition: { + text: 'Events where your organization sponsored, co-sponsored, or provided in-kind support.', + totalType: 'count', + total: '12', + dataSource: 'LFX', + }, + columns: ['Event name', 'Date', 'Sponsorship level', 'Reach'], + rows: [ + row(textCell('KubeCon EU 2025'), textCell('Mar 2025'), textCell('Diamond'), textCell('12,400 attendees')), + row(textCell('KubeCon NA 2024'), textCell('Nov 2024'), textCell('Gold'), textCell('8,200 attendees')), + ], + }, + + 'meetup-attendance': { + definition: { + text: "Attendance at community meetups organized under this project's foundation.", + totalType: 'count', + total: '1,240', + dataSource: 'LFX', + }, + columns: ['Our attendees', 'Meetup name', 'Date', 'Location'], + rows: [ + row(personCell(0), textCell(seed.name + ' Meetup Seattle'), textCell('Jan 2025'), textCell('Seattle, WA')), + row(personCell(1), textCell(seed.name + ' Meetup New York'), textCell('Oct 2024'), textCell('New York, NY')), + ], + }, + + 'certified-individuals': { + definition: { + text: "Employees who hold active certifications issued or recognized by this project's foundation.", + totalType: 'count', + total: '4,210', + dataSource: 'LFX', + }, + columns: ['Our individuals', 'Certification name', 'Date issued'], + rows: [ + row(personCell(0), textCell('Certified Kubernetes Administrator (CKA)'), textCell(FULL_DATES[0])), + row(personCell(1), textCell('Certified Kubernetes Application Developer (CKAD)'), textCell(FULL_DATES[1])), + row(personCell(2), textCell('Certified Kubernetes Security Specialist (CKS)'), textCell(FULL_DATES[2])), + ], + }, + }; +} + /** * Demo detail for one project. Returns `null` for an unknown slug so the page renders its * not-found (404) state. `orgUid` echoes into the response envelope; `orgName` personalizes @@ -507,5 +799,6 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu ecosystem: ecosystemCards(seed, seed.name, seed.foundationLabel), trend: trendSeries(seed), leaderboard: leaderboard(seed, orgName), + cardDetails: buildCardDetails(seed), }; } diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index c51343d8b..c122626a3 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -65,15 +65,38 @@ export interface OrgLensProjectInfluenceCard { caption: { prefix: string; emphasis: string; suffix: string }; } -/** One row in the monthly breakdown table shown in the card detail drawer. */ -export interface OrgLensInfluenceCardDetailRow { - month: string; - org: number; - avg: number; - /** Delta of org value vs the previous month (positive = up, negative = down, 0 = flat). */ - delta: number; +/** One cell in a card-specific detail table row. Exactly one of `person` or `text` should be set. */ +export interface OrgLensCardDetailCell { + /** Renders as avatar initials + name when set. */ + person?: { name: string; avatarUrl?: string }; + /** Plain text value when not a person cell. */ + text?: string; +} + +/** One row in the card-specific data table shown in the influence card detail drawer. */ +export interface OrgLensCardDetailRow { + cells: OrgLensCardDetailCell[]; } +/** Definition row shown at the top of the card detail drawer (mirrors app.lfx.dev format). */ +export interface OrgLensCardDefinition { + /** Plain-text description of what this metric counts or measures. */ + text: string; + /** Determines the middle column header: 'count' → "Total count for this project"; 'average' → "Average for this project" */ + totalType: 'count' | 'average'; + /** Pre-formatted total or average value, e.g. "78", "1,764", "48.3 days". */ + total: string; + /** Primary data source label, e.g. "LFX Insights", "LFX". */ + dataSource: string; +} + +/** Complete drawer detail section for one influence card: definition row + card-specific data table. */ +export interface OrgLensCardDetailSection { + definition: OrgLensCardDefinition; + /** Column header labels for the card-specific data table. */ + columns: string[]; + rows: OrgLensCardDetailRow[]; +} /** One monthly point on the Influence Trend chart. */ export interface OrgLensProjectTrendPoint { @@ -114,6 +137,8 @@ export interface OrgLensProjectDetailResponse { trend: OrgLensProjectTrendPoint[]; /** All organizations contributing to the project; the viewing-org row is always included. */ leaderboard: OrgLensProjectLeaderboardRow[]; + /** Keyed by card key — drawer definition + card-specific data table for each influence card. */ + cardDetails: Record; } /** Page-level lifecycle state for the Project Detail component. */ From 60f0dd9adf5f2c8b025a5ef1071f3b3a558818aa Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:09:19 -0400 Subject: [PATCH 27/80] fix(org-lens): move p-drawer header template outside @if block PrimeNG content projection requires ng-template#header to be a static direct child of p-drawer. When nested inside @if, the template is not found during drawer initialization, preventing the panel from opening. Signed-off-by: daniel qualls --- .../org-project-detail/org-project-detail.component.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 143c50aa3..3432e049f 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -424,8 +424,8 @@

Influence Trend

styleClass="xl:w-[40%] lg:w-[50%] md:w-[65%] sm:w-[85%] w-full" data-testid="influence-card-detail-drawer" (visibleChange)="drawerOpen.set($event)"> - @if (selectedCard(); as card) { - + + @if (selectedCard(); as card) {
@@ -435,8 +435,9 @@

Last 12 months · {{ card.scopeLabel ?? 'All projects' }}

-
- + } +
+ @if (selectedCard(); as card) {
From ee6725cd68f2d0a0b6bbeb99accc493150f629cb Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:11:07 -0400 Subject: [PATCH 28/80] fix(org-lens): add COCOMO tooltip to software value info icon Replace title attribute with pTooltip to match the reference page tooltip text: "Based on Constructive Cost Model (COCOMO)". Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.html | 3 ++- .../org/org-project-detail/org-project-detail.component.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 3432e049f..96dd1f9ae 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -89,7 +89,8 @@

{{ softwareValueLabel() }} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 1a5553258..8f34a031d 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -28,6 +28,7 @@ import type { import { parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; import { DrawerModule } from 'primeng/drawer'; +import { TooltipModule } from 'primeng/tooltip'; import type { ChartData, ChartOptions, ChartType } from 'chart.js'; import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; @@ -123,7 +124,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent, DrawerModule], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent, DrawerModule, TooltipModule], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { From 98c93532c8bd791f5da152fcff8bcfea86dd9841 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:23:43 -0400 Subject: [PATCH 29/80] feat(org-lens): add paginator to influence leaderboard tables Replace client-side top-N slice + pinned-row pattern with lfx-table paginator (10/25/50 rows, "Showing X to Y of Z" report). Inline both Technical and Ecosystem boards directly in the tab panel to avoid a ContentChild resolution failure that occurs when lfx-table is placed inside an ng-template rendered via ngTemplateOutlet. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 228 ++++++++++-------- .../org-project-detail.component.ts | 17 +- 2 files changed, 135 insertions(+), 110 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 96dd1f9ae..84a2f7783 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -264,24 +264,137 @@

Our Ecosystem Influence

- - + +
+
+
+
Technical Influence Leaderboard
+
Last 12 months
+
+
+ + +
+
+ @if (technicalBoard().length === 0) { +

+ {{ techSearch().trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} +

+ } @else { + + + + # + Organization + {{ scoreColumnLabel() }} + + + + + {{ row.rank }} + + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } + + + @if (isActivityMode()) { + {{ row.activityLabel }} + } @else { + + } + + + + + } +
+ + +
+
+
+
Ecosystem Influence Leaderboard
+
Last 12 months
+
+
+ + +
+
+ @if (ecosystemBoard().length === 0) { +

+ {{ ecoSearch().trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} +

+ } @else { + + + + # + Organization + {{ scoreColumnLabel() }} + + + + + {{ row.rank }} + + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } + + + @if (isActivityMode()) { + {{ row.activityLabel }} + } @else { + + } + + + + + } +
@@ -337,85 +450,6 @@

Influence Trend

- - -
-
-
-
{{ title }}
-
Last 12 months
-
-
- - -
-
- - @if (board.visible.length === 0) { -

- {{ searchValue.trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} -

- } @else { - - - - - - - - - - @for (row of board.visible; track row.orgName) { - - - - - - } - @if (board.pinned; as pinned) { - - - - - - } - -
#Organization{{ scoreColumnLabel() }}
{{ row.rank }} - {{ row.orgName }} - @if (row.isViewingOrg) { - You - } - - @if (isActivityMode()) { - {{ row.activityLabel }} - } @else { - - } -
- {{ pinned.rank }} - - {{ pinned.orgName }} - You - - @if (isActivityMode()) { - {{ pinned.activityLabel }} - } @else { - - } -
- } -
-
= new Set(['pd-in const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); -/** Rows shown per leaderboard before the viewing-org row is pinned below. */ -const LEADERBOARD_TOP_N = 10; - /** Dimension keyed by each side-by-side leaderboard. */ type LeaderboardDimension = 'technical' | 'ecosystem'; @@ -124,7 +122,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TagComponent, DrawerModule, TooltipModule], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TableComponent, TagComponent, DrawerModule, TooltipModule], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -333,12 +331,10 @@ export class OrgProjectDetailComponent { /** * Rank the leaderboard for one dimension (technical / ecosystem), then apply the search filter. - * With no search: top-N rows plus the viewing-org row pinned below when it falls outside top-N. - * With a search: all matching rows (the viewing-org row appears inline if it matches). + * Returns all matching rows — the paginator handles slicing. */ private buildBoard(dimension: LeaderboardDimension, search: string) { const valued = (this.detail()?.leaderboard ?? []).map((row) => ({ row, score: row.scores[dimension] })); - // Score desc; tie-break org name asc. valued.sort((a, b) => b.score - a.score || a.row.orgName.localeCompare(b.row.orgName)); const ranked = valued.map((entry, i) => { const bandMeta = BAND_TAG[bandForScore(entry.score)]; @@ -352,13 +348,8 @@ export class OrgProjectDetailComponent { isViewingOrg: entry.row.isViewingOrg, }; }); - const query = search.trim().toLowerCase(); - if (query) { - return { visible: ranked.filter((r) => r.orgName.toLowerCase().includes(query)), pinned: null }; - } - const pinned = ranked.find((r) => r.isViewingOrg && r.rank > LEADERBOARD_TOP_N) ?? null; - return { visible: ranked.slice(0, LEADERBOARD_TOP_N), pinned }; + return query ? ranked.filter((r) => r.orgName.toLowerCase().includes(query)) : ranked; } private initActiveTab(): OrgLensProjectDetailTab { From 59ecbf42494d9eade3c6c3bd089ae29ab13d823b Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:31:29 -0400 Subject: [PATCH 30/80] feat(org-lens): show org logos in influence leaderboard tables Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 30 ++++++++++++++----- .../org-project-detail.component.ts | 1 + .../org-lens-project-detail.demo-data.ts | 14 ++++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 84a2f7783..bee3387ed 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -312,10 +312,17 @@

Our Ecosystem Influence

[attr.data-testid]="row.isViewingOrg ? 'project-detail-leaderboard-technical-viewing-row' : null"> {{ row.rank }} - {{ row.orgName }} - @if (row.isViewingOrg) { - You - } +
+ @if (row.orgLogoUrl) { + + } @else { + + } + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
@if (isActivityMode()) { @@ -378,10 +385,17 @@

Our Ecosystem Influence

[attr.data-testid]="row.isViewingOrg ? 'project-detail-leaderboard-ecosystem-viewing-row' : null"> {{ row.rank }} - {{ row.orgName }} - @if (row.isViewingOrg) { - You - } +
+ @if (row.orgLogoUrl) { + + } @else { + + } + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
@if (isActivityMode()) { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index bc6a9d976..81392e6cd 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -341,6 +341,7 @@ export class OrgProjectDetailComponent { return { rank: i + 1, orgName: entry.row.orgName, + orgLogoUrl: entry.row.orgLogoUrl, initials: this.initialsFor(entry.row.orgName), activityLabel: this.activityFor(entry.score).toLocaleString(), bandLabel: bandMeta.label, diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts index 172550680..3a2ada071 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts @@ -439,6 +439,18 @@ const COMPETITORS: { name: string; strength: number; techBias: number; ecoBias: { name: 'Independent contributors', strength: 0.32, techBias: 1.0, ecoBias: 0.8 }, ]; +const ORG_LOGO_URLS: Record = { + Google: 'https://github.com/google.png', + 'Red Hat': 'https://github.com/RedHatOfficial.png', + Microsoft: 'https://github.com/microsoft.png', + Amazon: 'https://github.com/amzn.png', + VMware: 'https://github.com/vmware.png', + IBM: 'https://github.com/ibm.png', + Intel: 'https://github.com/intel.png', + SUSE: 'https://github.com/SUSE.png', + Huawei: 'https://github.com/Huawei.png', +}; + function round1(n: number): number { return Math.round(n * 10) / 10; } @@ -450,7 +462,7 @@ function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLe const combined = round1(c.strength * topScore); return { orgName: c.name, - orgLogoUrl: '', + orgLogoUrl: ORG_LOGO_URLS[c.name] ?? '', scores: { combined, technical: round1(Math.min(99, combined * c.techBias)), ecosystem: round1(Math.min(99, combined * c.ecoBias)) }, activityCount: Math.round(combined * 46), trendSparkline: ramp(combined, 0.86, 1), From fdeab1a7ae3b6afaaf6015befb385795417c169e Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:36:25 -0400 Subject: [PATCH 31/80] feat(org-lens): stacked area trend chart with time range selector on leaderboards tab Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 76 ++++++--- .../org-project-detail.component.ts | 156 ++++++++++++------ .../org-lens-project-detail.interface.ts | 3 + 3 files changed, 161 insertions(+), 74 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index bee3387ed..7b6ea189f 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -240,26 +240,48 @@

Our Ecosystem Influence

id="project-detail-tab-panel-pd-leaderboards" class="flex flex-col gap-4" [attr.aria-labelledby]="'project-detail-tab-trigger-pd-leaderboards'"> - -
- @for (opt of metricOptions; track opt.id) { - - } + +
+
+ @for (opt of metricOptions; track opt.id) { + + } +
+
+ @for (opt of timeRangeOptions; track opt.id) { + + } +
@@ -269,7 +291,7 @@

Our Ecosystem Influence

Technical Influence Leaderboard
-
Last 12 months
+
{{ leaderboardSubtitle() }}
@@ -342,7 +364,7 @@

Our Ecosystem Influence

Ecosystem Influence Leaderboard
-
Last 12 months
+
{{ leaderboardSubtitle() }}
@@ -411,19 +433,19 @@

Our Ecosystem Influence

- +

Influence Trend

- @if (hasTrendHistory()) { -
- + @if (hasStackedTrend()) { +
+
} @else { -

Not enough history yet.

+

No data available.

}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 81392e6cd..aa5f538e7 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -18,6 +18,7 @@ import { lfxColors } from '@lfx-one/shared/constants'; import type { OrgLensCardDetailSection, OrgLensLeaderboardMetric, + OrgLensLeaderboardTimeRange, OrgLensProjectBand, OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, @@ -54,6 +55,9 @@ const VALID_TABS: ReadonlySet = new Set(['pd-in const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); +const DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '12m'; +const VALID_TIME_RANGES: ReadonlySet = new Set(['3m', '6m', '12m']); + /** Dimension keyed by each side-by-side leaderboard. */ type LeaderboardDimension = 'technical' | 'ecosystem'; @@ -107,6 +111,29 @@ const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string }[] = [ { id: 'activity', label: 'Activity Count' }, ]; +const TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ + { id: '3m', label: '3 months' }, + { id: '6m', label: '6 months' }, + { id: '12m', label: '12 months' }, +]; + +const TIME_RANGE_MONTHS: Record = { '3m': 3, '6m': 6, '12m': 12 }; + +/** 11-slot palette for the stacked trend chart — top-10 companies + "All others". */ +const STACKED_PALETTE: string[] = [ + lfxColors.blue[600], + lfxColors.blue[400], + lfxColors.emerald[500], + lfxColors.emerald[400], + lfxColors.violet[500], + lfxColors.violet[400], + lfxColors.amber[500], + lfxColors.amber[400], + lfxColors.blue[300], + lfxColors.emerald[300], + lfxColors.gray[400], +]; + /** Band thresholds per Boysel et al. markup-mu (Leading / Contributing / Participating / Non-LF). */ function bandForScore(score: number): OrgLensProjectBand { if (score >= 80) return 'leading'; @@ -195,25 +222,31 @@ export class OrgProjectDetailComponent { (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem')) ); - // Influence Trend chart — Combined / Technical / Ecosystem overlays (legend toggles each line). - protected readonly hasTrendHistory = computed(() => (this.detail()?.trend.length ?? 0) >= 3); - protected readonly trendChartData = computed>(() => this.buildTrendData()); - protected readonly trendChartOptions: ChartOptions = this.buildTrendOptions(); - // Sparkline card options — stable class-level references so Angular passes them correctly through ng-template context. protected readonly lineCardOptions: ChartOptions = this.buildLineAreaCardOptions(); protected readonly barCardOptions: ChartOptions = this.buildBarCardOptions(); - // Leaderboards tab — URL-persisted metric toggle + two side-by-side dimension boards with search. + // Leaderboards tab — URL-persisted metric toggle + time range + two side-by-side boards + stacked trend. protected readonly metricOptions = METRIC_OPTIONS; + protected readonly timeRangeOptions = TIME_RANGE_OPTIONS; protected readonly metric = computed(() => this.initMetric()); + protected readonly timeRange = computed(() => this.initTimeRange()); protected readonly isActivityMode = computed(() => this.metric() === 'activity'); protected readonly scoreColumnLabel = computed(() => (this.isActivityMode() ? 'Activity (12mo)' : 'Influence Score')); + protected readonly leaderboardSubtitle = computed(() => { + const labels: Record = { '3m': 'Last 3 months', '6m': 'Last 6 months', '12m': 'Last 12 months' }; + return labels[this.timeRange()]; + }); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); protected readonly ecosystemBoard = computed(() => this.buildBoard('ecosystem', this.ecoSearch())); + // Stacked area trend chart — top-10 companies + "All others" stacked by combined influence score. + protected readonly hasStackedTrend = computed(() => (this.detail()?.leaderboard.length ?? 0) > 0); + protected readonly stackedTrendData = computed>(() => this.buildStackedTrend()); + protected readonly stackedTrendOptions: ChartOptions = this.buildStackedTrendOptions(); + // After cards populate (async data load), check whether each track is actually scrollable. private readonly _scrollEffect = effect(() => { const techLen = this.technicalCards().length; @@ -269,6 +302,16 @@ export class OrgProjectDetailComponent { }); } + protected setTimeRange(range: OrgLensLeaderboardTimeRange): void { + if (this.timeRange() === range) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { range: range === DEFAULT_TIME_RANGE ? null : range }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + protected onSearch(dimension: LeaderboardDimension, event: Event): void { const value = (event.target as HTMLInputElement).value; (dimension === 'technical' ? this.techSearch : this.ecoSearch).set(value); @@ -324,6 +367,11 @@ export class OrgProjectDetailComponent { return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; } + private initTimeRange(): OrgLensLeaderboardTimeRange { + const raw = this.queryParamMap().get('range'); + return raw && VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : DEFAULT_TIME_RANGE; + } + /** Per-dimension activity count (12mo) derived from that dimension's score, so the two boards differ. */ private activityFor(score: number): number { return Math.round(score * 46); @@ -567,62 +615,76 @@ export class OrgProjectDetailComponent { return { labels: this.monthLabels, datasets }; } - private buildTrendData(): ChartData { - const trend = this.detail()?.trend ?? []; - const labels = trend.map((point) => this.formatTrendMonth(point.month)); - const line = (label: string, data: number[], color: string) => ({ - label, - data, - borderColor: color, - backgroundColor: color, - tension: 0.3, - borderWidth: 2, - pointRadius: 0, - pointHoverRadius: 4, - fill: false, + /** + * Builds a stacked area chart dataset from the leaderboard: top-10 companies by combined + * score each get their own band; any remaining companies are summed into "All others". + * Monthly series are derived deterministically from each org's current score so the chart + * shows a plausible trajectory without requiring per-company historical fixture data. + */ + private buildStackedTrend(): ChartData { + const board = this.detail()?.leaderboard ?? []; + if (board.length === 0) return { labels: [], datasets: [] }; + + const months = TIME_RANGE_MONTHS[this.timeRange()]; + const labels = this.monthLabels.slice(-months); + + const sorted = [...board].sort((a, b) => b.scores.combined - a.scores.combined); + const top10 = sorted.slice(0, 10); + const rest = sorted.slice(10); + + interface StackEntry { name: string; score: number; seed: number } + const entries: StackEntry[] = top10.map((r, i) => ({ name: r.orgName, score: r.scores.combined, seed: i })); + if (rest.length > 0) { + const avg = rest.reduce((s, r) => s + r.scores.combined, 0) / rest.length; + entries.push({ name: 'All others', score: avg, seed: 10 }); + } + + const datasets = entries.map((entry, i) => { + const color = STACKED_PALETTE[i] ?? lfxColors.gray[300]; + return { + label: entry.name, + data: this.buildTrendSeries(entry.score, months, entry.seed), + backgroundColor: color + 'cc', + borderColor: color, + borderWidth: 1, + fill: true, + tension: 0.3, + pointRadius: 0, + pointHoverRadius: 3, + }; }); - return { - labels, - datasets: [ - line( - 'Combined', - trend.map((p) => p.combined), - lfxColors.blue[500] - ), - line( - 'Technical', - trend.map((p) => p.technical), - lfxColors.emerald[500] - ), - line( - 'Ecosystem', - trend.map((p) => p.ecosystem), - lfxColors.violet[500] - ), - ], - }; + + return { labels, datasets }; } - private buildTrendOptions(): ChartOptions { + private buildStackedTrendOptions(): ChartOptions { return { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { - legend: { display: true, position: 'top', labels: { usePointStyle: true, boxWidth: 8 } }, + legend: { display: true, position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 16 } }, tooltip: { mode: 'index', intersect: false }, }, scales: { x: { grid: { display: false } }, - y: { beginAtZero: false, grace: '5%', ticks: { maxTicksLimit: 6 } }, + y: { stacked: true, beginAtZero: true, ticks: { maxTicksLimit: 6 } }, }, }; } - /** "2025-07" → "Jul 2025" for the trend x-axis. */ - private formatTrendMonth(month: string): string { - const [year, mon] = month.split('-').map((n) => Number(n)); - if (!year || !mon) return month; - return new Date(year, mon - 1, 1).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + /** + * Generates a deterministic N-month series ending near `currentScore`. + * Each seed value shifts the wave phase so companies look distinct. + */ + private buildTrendSeries(currentScore: number, months: number, seed: number): number[] { + const startRatio = 0.72 + (seed % 5) * 0.04; + const series: number[] = []; + for (let i = 0; i < months; i++) { + const t = months === 1 ? 1 : i / (months - 1); + const wave = Math.sin((i + seed * 2.1) * 0.8) * 0.05; + series.push(Math.round(currentScore * (startRatio + (1 - startRatio) * t + wave) * 10) / 10); + } + return series; } } diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index c122626a3..dfe00e072 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -22,6 +22,9 @@ export type OrgLensScoreType = 'combined' | 'technical' | 'ecosystem'; /** Leaderboard metric toggle (`?metric=`). Activity Count mode hides the Trend + Band columns. */ export type OrgLensLeaderboardMetric = 'influence' | 'activity'; +/** Time range selector (`?range=`) for the leaderboards tab — affects the stacked trend chart and leaderboard period labels. */ +export type OrgLensLeaderboardTimeRange = '3m' | '6m' | '12m'; + /** Tab strip ids (URL `?tab=`). */ export type OrgLensProjectDetailTab = 'pd-influence' | 'pd-leaderboards'; From ada3eab58a0a7289cc3697ff8670ffb7e0701aa5 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:43:27 -0400 Subject: [PATCH 32/80] fix(org-lens): use fill: stack for stacked area trend chart in Chart.js 4.x Signed-off-by: daniel qualls --- .../org-project-detail/org-project-detail.component.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index aa5f538e7..cf6b518b6 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -644,10 +644,12 @@ export class OrgProjectDetailComponent { return { label: entry.name, data: this.buildTrendSeries(entry.score, months, entry.seed), - backgroundColor: color + 'cc', + backgroundColor: color + '99', borderColor: color, - borderWidth: 1, - fill: true, + borderWidth: 1.5, + // 'stack' fills from this line to the visual position of the dataset below it, + // which is the correct Chart.js 4.x primitive for stacked area charts. + fill: 'stack', tension: 0.3, pointRadius: 0, pointHoverRadius: 3, From 7dec43a20a337c0a3e3a679c6cbf8e6fd83329e1 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 14:56:05 -0400 Subject: [PATCH 33/80] feat(org-lens): add tab bar component and restyle metric toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OrgProjectDetailTabBarComponent — a white bordered row with an ng-content left slot and List/Calendar-style time range buttons on the right. Place it at the top of both the Influence and Leaderboards tab panels so every panel shares the same page-level time range selector. On Leaderboards, the Calculated Influence / Activity Count metric toggle moves below the tab bar and is restyled as individual rounded-lg bordered buttons with Font Awesome icons, matching the meetings page selector style. Make sparkline cards on the Influence tab time-range-aware: technicalCards and ecosystemCards now slice sparklines and month labels to the active time range so selecting 3m/6m/12m affects the chart viewport on both tabs. Signed-off-by: daniel qualls --- .../org-project-detail-tab-bar.component.html | 28 +++++++ .../org-project-detail-tab-bar.component.ts | 15 ++++ .../org-project-detail.component.html | 75 ++++++++----------- .../org-project-detail.component.ts | 38 ++++++---- 4 files changed, 98 insertions(+), 58 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html new file mode 100644 index 000000000..82a303daa --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html @@ -0,0 +1,28 @@ + + + +
+
+ +
+
+ @for (opt of timeRangeOptions(); track opt.id) { + + } +
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts new file mode 100644 index 000000000..dc657b661 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -0,0 +1,15 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, input, output } from '@angular/core'; +import type { OrgLensLeaderboardTimeRange } from '@lfx-one/shared/interfaces'; + +@Component({ + selector: 'lfx-org-project-detail-tab-bar', + templateUrl: './org-project-detail-tab-bar.component.html', +}) +export class OrgProjectDetailTabBarComponent { + public readonly timeRange = input.required(); + public readonly timeRangeOptions = input.required<{ id: OrgLensLeaderboardTimeRange; label: string }[]>(); + public readonly timeRangeChange = output(); +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 7b6ea189f..8256d1570 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -145,6 +145,13 @@

+ + + +
@@ -240,48 +247,32 @@

Our Ecosystem Influence

id="project-detail-tab-panel-pd-leaderboards" class="flex flex-col gap-4" [attr.aria-labelledby]="'project-detail-tab-trigger-pd-leaderboards'"> - -
-
- @for (opt of metricOptions; track opt.id) { - - } -
-
- @for (opt of timeRangeOptions; track opt.id) { - - } -
+ + + + + +
+ @for (opt of metricOptions; track opt.id) { + + }
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index cf6b518b6..c29644e07 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -14,6 +14,7 @@ import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { TableComponent } from '@components/table/table.component'; import { TagComponent } from '@components/tag/tag.component'; +import { OrgProjectDetailTabBarComponent } from './org-project-detail-tab-bar.component'; import { lfxColors } from '@lfx-one/shared/constants'; import type { OrgLensCardDetailSection, @@ -106,9 +107,9 @@ const BAND_CHIP_CLASS: Record = { 'non-lf': 'inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600', }; -const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string }[] = [ - { id: 'influence', label: 'Calculated Influence' }, - { id: 'activity', label: 'Activity Count' }, +const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string; icon: string }[] = [ + { id: 'influence', label: 'Calculated Influence', icon: 'fa-light fa-chart-bar' }, + { id: 'activity', label: 'Activity Count', icon: 'fa-light fa-list-ol' }, ]; const TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ @@ -149,7 +150,7 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, TableComponent, TagComponent, DrawerModule, TooltipModule], + imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, OrgProjectDetailTabBarComponent, TableComponent, TagComponent, DrawerModule, TooltipModule], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -215,12 +216,14 @@ export class OrgProjectDetailComponent { // Our Influence tab — Technical + Ecosystem cards (per-card chart type and data). private readonly monthLabels: string[] = this.buildMonthLabels(); - protected readonly technicalCards = computed(() => - (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')) - ); - protected readonly ecosystemCards = computed(() => - (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem')) - ); + protected readonly technicalCards = computed(() => { + const months = TIME_RANGE_MONTHS[this.timeRange()]; + return (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical', months)); + }); + protected readonly ecosystemCards = computed(() => { + const months = TIME_RANGE_MONTHS[this.timeRange()]; + return (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem', months)); + }); // Sparkline card options — stable class-level references so Angular passes them correctly through ng-template context. protected readonly lineCardOptions: ChartOptions = this.buildLineAreaCardOptions(); @@ -560,16 +563,19 @@ export class OrgProjectDetailComponent { return out; } - private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem'): InfluenceCardVm { + private toInfluenceCard(card: OrgLensProjectInfluenceCard, colorHex: string, group: 'technical' | 'ecosystem', months = 12): InfluenceCardVm { const variant = this.chartVariantFor(card.key); const valueSuffix = card.key === 'avg-merge-time' ? ' days' : ''; + const sparkline = card.sparkline.slice(-months); + const projectSparkline = card.projectSparkline.slice(-months); + const labels = this.monthLabels.slice(-months); return { key: card.key, title: card.label, scopeLabel: card.scopeLabel, - hasData: card.sparkline.length > 0, + hasData: sparkline.length > 0, chartType: (variant === 'bar' ? 'bar' : 'line') as ChartType, - chartData: this.buildCardChartData(card.sparkline, card.projectSparkline, colorHex, variant), + chartData: this.buildCardChartData(sparkline, projectSparkline, colorHex, variant, labels), chartOptions: variant === 'bar' ? this.buildBarCardOptions(valueSuffix) : this.buildLineAreaCardOptions(valueSuffix), valueSuffix, caption: card.caption, @@ -578,10 +584,10 @@ export class OrgProjectDetailComponent { }; } - private buildCardChartData(series: number[], projectSeries: number[], colorHex: string, variant: 'area' | 'bar' | 'line'): ChartData { + private buildCardChartData(series: number[], projectSeries: number[], colorHex: string, variant: 'area' | 'bar' | 'line', labels: string[]): ChartData { if (variant === 'bar') { return { - labels: this.monthLabels, + labels, datasets: [{ label: 'Your company', data: series, backgroundColor: colorHex + '99', borderColor: colorHex, borderWidth: 0, borderRadius: 4 }], }; } @@ -612,7 +618,7 @@ export class OrgProjectDetailComponent { pointHoverRadius: 0, }); } - return { labels: this.monthLabels, datasets }; + return { labels, datasets }; } /** From b3b61bc0e5cb97d85c65e6dd2ef5d2248347352c Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 15:05:48 -0400 Subject: [PATCH 34/80] fix(org-lens): rework tab bar to match app.lfx.dev meetings page pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Our Influence / Leaderboards tab pills inside the white bordered row (matching the Upcoming/Past layout on the meetings page). Replace the 3m/6m/12m button group with a native dropdown offering 1 year, 2 years, and All time. Update OrgLensLeaderboardTimeRange type and all constants. Move the Calculated Influence / Activity Count metric toggle to be right-aligned below the bar (matching the List/Calendar button placement on the meetings page). Remove the per-panel tab bar instances now that the single bar above the @switch covers both panels. Remove the parent's onTabKeydown handler — keyboard navigation is now handled inside the tab bar component with @ViewChildren focus management. Expand buildMonthLabels to 36 months to support the all-time range, and fix the sparkline label slice to use sparkline.length so chart labels always match available data points. Signed-off-by: daniel qualls --- .../org-project-detail-tab-bar.component.html | 48 +++++++++++------ .../org-project-detail-tab-bar.component.ts | 35 +++++++++++- .../org-project-detail.component.html | 53 ++++--------------- .../org-project-detail.component.ts | 37 ++++--------- .../org-lens-project-detail.interface.ts | 2 +- 5 files changed, 87 insertions(+), 88 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html index 82a303daa..67d54e919 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html @@ -4,25 +4,41 @@
-
- -
-
- @for (opt of timeRangeOptions(); track opt.id) { + +
+ @for (tab of tabs(); track tab.id) { }
+ + +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts index dc657b661..4a680e611 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -1,15 +1,46 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, input, output } from '@angular/core'; -import type { OrgLensLeaderboardTimeRange } from '@lfx-one/shared/interfaces'; +import { isPlatformBrowser } from '@angular/common'; +import { Component, ElementRef, inject, input, output, PLATFORM_ID, QueryList, ViewChildren } from '@angular/core'; +import type { OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-org-project-detail-tab-bar', templateUrl: './org-project-detail-tab-bar.component.html', }) export class OrgProjectDetailTabBarComponent { + @ViewChildren('tabBtn') private readonly tabBtns!: QueryList>; + + private readonly platformId = inject(PLATFORM_ID); + + public readonly tabs = input.required<{ id: OrgLensProjectDetailTab; label: string }[]>(); + public readonly activeTab = input.required(); public readonly timeRange = input.required(); public readonly timeRangeOptions = input.required<{ id: OrgLensLeaderboardTimeRange; label: string }[]>(); + public readonly tabChange = output(); public readonly timeRangeChange = output(); + + protected onTabKeydown(event: KeyboardEvent): void { + const ids = this.tabs().map((t) => t.id); + const idx = ids.indexOf(this.activeTab()); + let next: number | null = null; + if (event.key === 'ArrowRight') next = (idx + 1) % ids.length; + else if (event.key === 'ArrowLeft') next = (idx - 1 + ids.length) % ids.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = ids.length - 1; + if (next !== null) { + event.preventDefault(); + this.tabChange.emit(ids[next]); + if (isPlatformBrowser(this.platformId)) { + const n = next; + setTimeout(() => this.tabBtns.get(n)?.nativeElement.focus()); + } + } + } + + protected onRangeChange(event: Event): void { + const value = (event.target as HTMLSelectElement).value as OrgLensLeaderboardTimeRange; + this.timeRangeChange.emit(value); + } } diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 8256d1570..e9424de73 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -110,32 +110,15 @@

- @for (tab of tabs; track tab.id) { - - } -

+ + + @switch (activeTab()) { @@ -145,13 +128,6 @@

- - - -
@@ -247,15 +223,8 @@

Our Ecosystem Influence

id="project-detail-tab-panel-pd-leaderboards" class="flex flex-col gap-4" [attr.aria-labelledby]="'project-detail-tab-trigger-pd-leaderboards'"> - - - - - -
+ +
@for (opt of metricOptions; track opt.id) {
- - + + +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts index 4a680e611..13fb9d85a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -3,7 +3,7 @@ import { isPlatformBrowser } from '@angular/common'; import { Component, ElementRef, inject, input, output, PLATFORM_ID, QueryList, ViewChildren } from '@angular/core'; -import type { OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; +import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-org-project-detail-tab-bar', @@ -18,8 +18,12 @@ export class OrgProjectDetailTabBarComponent { public readonly activeTab = input.required(); public readonly timeRange = input.required(); public readonly timeRangeOptions = input.required<{ id: OrgLensLeaderboardTimeRange; label: string }[]>(); + /** Optional metric toggle — rendered between the tab pills and time range when provided. */ + public readonly metric = input(null); + public readonly metricOptions = input<{ id: OrgLensLeaderboardMetric; label: string; icon: string }[] | null>(null); public readonly tabChange = output(); public readonly timeRangeChange = output(); + public readonly metricChange = output(); protected onTabKeydown(event: KeyboardEvent): void { const ids = this.tabs().map((t) => t.id); diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index e9424de73..5a8fa650e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -110,14 +110,17 @@

+ (timeRangeChange)="setTimeRange($event)" + (metricChange)="setMetric($event)"> @@ -223,27 +226,6 @@

Our Ecosystem Influence

id="project-detail-tab-panel-pd-leaderboards" class="flex flex-col gap-4" [attr.aria-labelledby]="'project-detail-tab-trigger-pd-leaderboards'"> - -
- @for (opt of metricOptions; track opt.id) { - - } -
-
@@ -251,7 +233,6 @@

Our Ecosystem Influence

Technical Influence Leaderboard
-
{{ leaderboardSubtitle() }}
@@ -324,7 +305,6 @@

Our Ecosystem Influence

Ecosystem Influence Leaderboard
-
{{ leaderboardSubtitle() }}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 4695640aa..235acfac5 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -236,10 +236,6 @@ export class OrgProjectDetailComponent { protected readonly timeRange = computed(() => this.initTimeRange()); protected readonly isActivityMode = computed(() => this.metric() === 'activity'); protected readonly scoreColumnLabel = computed(() => (this.isActivityMode() ? 'Activity (12mo)' : 'Influence Score')); - protected readonly leaderboardSubtitle = computed(() => { - const labels: Record = { '1y': 'Last 12 months', '2y': 'Last 2 years', 'all': 'All time' }; - return labels[this.timeRange()]; - }); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); @@ -628,16 +624,23 @@ export class OrgProjectDetailComponent { entries.push({ name: 'All others', score: avg, seed: 10 }); } + // Build raw series, then normalize each month so all series sum to 100%. + const rawSeries: number[][] = entries.map((entry) => this.buildTrendSeries(entry.score, months, entry.seed)); + const pctSeries: number[][] = rawSeries.map((series, si) => + series.map((val, mi) => { + const total = rawSeries.reduce((sum, s) => sum + (s[mi] ?? 0), 0); + return total > 0 ? Math.round((val / total) * 1000) / 10 : 0; + }) + ); + const datasets = entries.map((entry, i) => { const color = STACKED_PALETTE[i] ?? lfxColors.gray[300]; return { label: entry.name, - data: this.buildTrendSeries(entry.score, months, entry.seed), + data: pctSeries[i], backgroundColor: color + '99', borderColor: color, borderWidth: 1.5, - // 'stack' fills from this line to the visual position of the dataset below it, - // which is the correct Chart.js 4.x primitive for stacked area charts. fill: 'stack', tension: 0.3, pointRadius: 0, @@ -655,11 +658,15 @@ export class OrgProjectDetailComponent { interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: true, position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 16 } }, - tooltip: { mode: 'index', intersect: false }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { label: (ctx) => ` ${ctx.dataset.label ?? ''}: ${(ctx.parsed as { y: number }).y.toFixed(1)}%` }, + }, }, scales: { x: { grid: { display: false } }, - y: { stacked: true, beginAtZero: true, ticks: { maxTicksLimit: 6 } }, + y: { stacked: true, min: 0, max: 100, ticks: { maxTicksLimit: 6, callback: (v) => `${v}%` } }, }, }; } From 9fbb1d94cf4af88056084cab86f1ab80a5624e6a Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 24 Jun 2026 17:10:38 -0400 Subject: [PATCH 36/80] fix(org-lens): tab bar layout, metric toggle placement, and chart polish LFXV2-1885 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply flex container classes to tab bar host element (fixes pill row appearing as a separate element above the bordered row) - Move metric toggle inside tab bar between tab pills and time range; only rendered on the Leaderboards tab via conditional input binding - Replace time range - @for (opt of timeRangeOptions(); track opt.id) { - - } - -
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts index 13fb9d85a..285f1b93a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -7,6 +7,10 @@ import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProj @Component({ selector: 'lfx-org-project-detail-tab-bar', + host: { + class: 'flex w-full items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white px-4 py-2.5 shadow-sm', + 'data-testid': 'project-detail-controls-bar', + }, templateUrl: './org-project-detail-tab-bar.component.html', }) export class OrgProjectDetailTabBarComponent { @@ -18,7 +22,7 @@ export class OrgProjectDetailTabBarComponent { public readonly activeTab = input.required(); public readonly timeRange = input.required(); public readonly timeRangeOptions = input.required<{ id: OrgLensLeaderboardTimeRange; label: string }[]>(); - /** Optional metric toggle — rendered between the tab pills and time range when provided. */ + /** When provided, renders the metric toggle between the tab pills and the time dropdown. */ public readonly metric = input(null); public readonly metricOptions = input<{ id: OrgLensLeaderboardMetric; label: string; icon: string }[] | null>(null); public readonly tabChange = output(); @@ -42,9 +46,4 @@ export class OrgProjectDetailTabBarComponent { } } } - - protected onRangeChange(event: Event): void { - const value = (event.target as HTMLSelectElement).value as OrgLensLeaderboardTimeRange; - this.timeRangeChange.emit(value); - } } diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 5a8fa650e..ad4362295 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -5,10 +5,10 @@ @switch (pageState()) { @case ('loading') {
-
-
-
-
+ + + +
} @@ -110,7 +110,7 @@

Our Technical Influence

@@ -277,9 +282,17 @@

Our Ecosystem Influence

@if (row.orgLogoUrl) { - + } @else { - + } {{ row.orgName }} @if (row.isViewingOrg) { @@ -309,12 +322,13 @@

Our Ecosystem Influence

@@ -349,9 +363,17 @@

Our Ecosystem Influence

@if (row.orgLogoUrl) { - + } @else { - + } {{ row.orgName }} @if (row.isViewingOrg) { @@ -381,7 +403,7 @@

Influence Trend

@if (hasStackedTrend()) { -
+
} @else { @@ -426,7 +448,6 @@

Influence Trend

- = { '1y': 12, '2y': 24, 'all': 36 }; +const TIME_RANGE_MONTHS: Record = { '1y': 12, '2y': 24, all: 36 }; /** 11-slot palette for the stacked trend chart — top-10 companies + "All others". */ const STACKED_PALETTE: string[] = [ @@ -150,7 +150,19 @@ function bandForScore(score: number): OrgLensProjectBand { */ @Component({ selector: 'lfx-org-project-detail', - imports: [NgTemplateOutlet, BreadcrumbComponent, ChartComponent, EmptyStateComponent, OrgProjectDetailTabBarComponent, TableComponent, TagComponent, DrawerModule, TooltipModule], + imports: [ + NgTemplateOutlet, + BreadcrumbComponent, + ChartComponent, + EmptyStateComponent, + OrgProjectDetailTabBarComponent, + TableComponent, + TagComponent, + DrawerModule, + InputTextModule, + SkeletonModule, + TooltipModule, + ], templateUrl: './org-project-detail.component.html', }) export class OrgProjectDetailComponent { @@ -401,7 +413,8 @@ export class OrgProjectDetailComponent { }), switchMap(([orgUid, orgName, projectSlug]) => { return this.detailService.getProjectDetail(orgUid, orgName, projectSlug).pipe( - catchError(() => { + catchError((err: unknown) => { + console.error('[OrgProjectDetail] failed to load project detail', err); this.fetchError.set(true); this.fetchLoading.set(false); return of(null); @@ -460,9 +473,7 @@ export class OrgProjectDetailComponent { } /** Shared external-tooltip callback: positions a fixed DOM overlay near the cursor. */ - private buildExternalTooltipFn( - valueSuffix = '' - ): (args: { + private buildExternalTooltipFn(valueSuffix = ''): (args: { chart: { canvas: HTMLElement & { getBoundingClientRect(): DOMRect } }; tooltip: { opacity: number; @@ -563,7 +574,13 @@ export class OrgProjectDetailComponent { }; } - private buildCardChartData(series: number[], projectSeries: number[], colorHex: string, variant: 'area' | 'bar' | 'line', labels: string[]): ChartData { + private buildCardChartData( + series: number[], + projectSeries: number[], + colorHex: string, + variant: 'area' | 'bar' | 'line', + labels: string[] + ): ChartData { if (variant === 'bar') { return { labels, @@ -617,7 +634,11 @@ export class OrgProjectDetailComponent { const top10 = sorted.slice(0, 10); const rest = sorted.slice(10); - interface StackEntry { name: string; score: number; seed: number } + interface StackEntry { + name: string; + score: number; + seed: number; + } const entries: StackEntry[] = top10.map((r, i) => ({ name: r.orgName, score: r.scores.combined, seed: i })); if (rest.length > 0) { const avg = rest.reduce((s, r) => s + r.scores.combined, 0) / rest.length; @@ -626,18 +647,23 @@ export class OrgProjectDetailComponent { // Build raw series, then normalize each month so all series sum to 100%. const rawSeries: number[][] = entries.map((entry) => this.buildTrendSeries(entry.score, months, entry.seed)); - const pctSeries: number[][] = rawSeries.map((series, si) => - series.map((val, mi) => { - const total = rawSeries.reduce((sum, s) => sum + (s[mi] ?? 0), 0); - return total > 0 ? Math.round((val / total) * 1000) / 10 : 0; - }) + const monthTotals = rawSeries[0].map((_, mi) => rawSeries.reduce((sum, s) => sum + (s[mi] ?? 0), 0)); + const pctSeries: number[][] = rawSeries.map((series) => + series.map((val, mi) => (monthTotals[mi] > 0 ? Math.round((val / monthTotals[mi]) * 1000) / 10 : 0)) ); - const datasets = entries.map((entry, i) => { - const color = STACKED_PALETTE[i] ?? lfxColors.gray[300]; + // Rank by most-recent-month share (most influential now → first in ranked[]). + const lastIdx = months - 1; + const ranked = entries.map((entry, i) => ({ entry, pct: pctSeries[i], lastShare: pctSeries[i][lastIdx] ?? 0 })).sort((a, b) => b.lastShare - a.lastShare); + + // datasets[0] = most influential (bottom of stack, first/left in legend). + // datasets[N] = least influential (top of stack, last/right in legend). + // This matches the standard 100% stacked area convention used in the reference design. + const datasets = ranked.map((item, rankIdx) => { + const color = STACKED_PALETTE[rankIdx] ?? lfxColors.gray[300]; return { - label: entry.name, - data: pctSeries[i], + label: item.entry.name, + data: item.pct, backgroundColor: color + '99', borderColor: color, borderWidth: 1.5, @@ -672,17 +698,33 @@ export class OrgProjectDetailComponent { } /** - * Generates a deterministic N-month series ending near `currentScore`. - * Each seed value shifts the wave phase so companies look distinct. + * Generates a deterministic N-month series for each company using a shaped trajectory + * so the stacked chart shows real competitive dynamics — leaders dipping, challengers + * surging, mid-tier players crossing over. Each seed maps to a [start, mid, end] ratio + * applied to the company's current score, interpolated through a configurable midpoint. + * Values are clamped to 0.5 so no company disappears from the chart entirely. */ private buildTrendSeries(currentScore: number, months: number, seed: number): number[] { - const startRatio = 0.72 + (seed % 5) * 0.04; - const series: number[] = []; - for (let i = 0; i < months; i++) { + // [startRatio, midRatio, endRatio, midPoint(0..1)] + // Ratios are multiples of currentScore. end is always 1.0 (= currentScore). + const SHAPES: [number, number, number, number][] = [ + [0.88, 1.1, 1.0, 0.45], // 0: stable dominant — slight mid-peak, very steady presence + [0.52, 0.76, 1.0, 0.55], // 1: strong recent growth — was behind, now surging + [1.34, 1.18, 1.0, 0.5], // 2: gradual decline — was clearly ahead, ceding ground + [0.4, 0.62, 1.0, 0.62], // 3: late-stage surge — slow start, rapid recent acceleration + [1.14, 0.66, 1.0, 0.38], // 4: dip-and-recovery — sharp early drop then strong comeback + [1.48, 1.24, 1.0, 0.5], // 5: steep decline — large historical lead, losing fast + [0.76, 0.88, 1.0, 0.5], // 6: steady incremental growth — consistent upward drift + [0.46, 1.3, 1.0, 0.56], // 7: spike-then-settle — brief surge at mid-period, then normalises + [0.95, 0.56, 1.0, 0.44], // 8: valley — noticeable mid-period trough, full recovery + [1.24, 1.12, 1.0, 0.5], // 9: gentle decline — modest but sustained loss of share + [1.0, 1.0, 1.0, 0.5], // 10: flat (All others) — stable catch-all bucket + ]; + const [startR, midR, endR, midT] = SHAPES[Math.min(seed, SHAPES.length - 1)]; + return Array.from({ length: months }, (_, i) => { const t = months === 1 ? 1 : i / (months - 1); - const wave = Math.sin((i + seed * 2.1) * 0.8) * 0.05; - series.push(Math.round(currentScore * (startRatio + (1 - startRatio) * t + wave) * 10) / 10); - } - return series; + const ratio = t <= midT ? startR + (midR - startR) * (t / midT) : midR + (endR - midR) * ((t - midT) / (1 - midT)); + return Math.max(0.5, Math.round(currentScore * ratio * 10) / 10); + }); } } From d6ada950db46ec5497f2db0e8e87c1ef6a0fb4b7 Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Wed, 24 Jun 2026 21:03:18 -0600 Subject: [PATCH 37/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../src/app/layouts/main-layout/main-layout.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index 2384b995a..d14d7213d 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -509,7 +509,9 @@ export class MainLayoutComponent { icon: 'fa-light fa-display', routerLink: '/org/memberships', }, - { label: 'Projects', icon: 'fa-light fa-folder', routerLink: '/org/projects' }, + // INFO: Future Epic implementation — the Projects page is hidden until the + // org-projects drilldown is built. Restore the entry below to re-enable it. + // { label: 'Projects', icon: 'fa-light fa-folder', routerLink: '/org/projects' }, // INFO: Future Epic implementation — the ROI page is hidden until the org ROI // feature is built. Restore the entry below to re-enable it. // { label: 'ROI', icon: 'fa-light fa-chart-line-up', routerLink: '/org/roi' }, From 5c325d9a817bdefbe1849bfafdf5db3915966acc Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Wed, 24 Jun 2026 21:04:28 -0600 Subject: [PATCH 38/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index e0304543f..1e1896efe 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -54,6 +54,8 @@ test.describe('Org Project Detail — testid resolution', () => { for (const key of ['collaboration', 'meeting-attendance', 'board-members', 'committee-members']) { await expect(page.getByTestId(`project-detail-ecosystem-card-${key}`)).toBeVisible(); } + + await page.getByTestId('project-detail-tab-pd-leaderboards').click(); await expect(page.getByTestId('project-detail-trend-group')).toBeVisible(); }); }); From a8a232973e43104c2fa9cfe54aba6c921870cdd7 Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Wed, 24 Jun 2026 21:04:40 -0600 Subject: [PATCH 39/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index 1e1896efe..d0e8085b2 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -75,7 +75,7 @@ test.describe('Org Project Detail — tab strip', () => { test('deep-links to the Leaderboards tab via ?tab=', async ({ page }) => { await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('project-detail-leaderboard-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); }); test('arrow keys move between tabs', async ({ page }) => { From 4480a2219de2977a4ce3917574c9dfe98071ee68 Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Wed, 24 Jun 2026 21:04:53 -0600 Subject: [PATCH 40/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index ad4362295..47e6d7d44 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -29,9 +29,9 @@ icon="fa-light fa-folder-open" title="Project not found" subtitle="We couldn't find that project for your organization." - ctaLabel="Back to Projects" + ctaLabel="Back to Org Overview" ctaIcon="fa-light fa-arrow-left" - [ctaRoute]="['/org/projects']" + [ctaRoute]="['/org/overview']" data-testid="project-detail-not-found"> } From 91bd0e78a8bdc387b9373716e16f464da9f8a96e Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Wed, 24 Jun 2026 21:05:03 -0600 Subject: [PATCH 41/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index d0e8085b2..88e87b465 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -5,10 +5,8 @@ * Org Lens · Project Detail Page E2E Tests (LFXV2-1885) * * Covers the acceptance criteria for the per-project Org Lens view: - * - data-testid resolution smoke test (breadcrumb, hero, tabs, card groups, trend) - * - tab strip switching (click + keyboard) with ?tab= URL persistence - * - leaderboard ranking, score/metric toggles with ?score=/?metric= persistence, - * Activity Count hiding the Trend + Band columns, and Show more pagination + * - leaderboard ranking, metric toggle with ?metric= persistence, + * Activity Count hiding the Band column * - not-found (404) panel for an unknown slug * * Prerequisites: From 1e047c4a5d03c05f39395da58b755b58db46703f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:07:05 +0000 Subject: [PATCH 42/80] Apply remaining changes --- .../org-overview-foundations-and-projects.component.html | 8 +++++--- .../org-project-detail/org-project-detail.component.html | 2 +- .../org-project-detail/org-project-detail.component.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html index df1a5e24d..72b978dc3 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html @@ -91,11 +91,13 @@

@for (project of row.projects; track project.projectId) { - diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 4ba4bb7c3..2419bae49 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -439,7 +439,7 @@ export class OrgProjectDetailComponent { private initBreadcrumb(): MenuItem[] { const hero = this.hero(); - const root: MenuItem = { label: 'Projects', routerLink: ['/org/projects'] }; + const root: MenuItem = { label: 'Org Overview', routerLink: ['/org/overview'] }; return hero ? [root, { label: hero.projectName }] : [root]; } From 4fdd6a0f46e178815deaab37ae93fc067f7974c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:07:32 +0000 Subject: [PATCH 43/80] fix(org-lens): wire project row handlers and fix broken /org/projects links - Wire (click)/(keydown) + role/tabindex on LF project rows in the Foundations-and-Projects overview table; removes the stale 'Future Epic' comment now that the /org/projects/:projectSlug route exists - Fix breadcrumb home routerLink and comment to point at /org/overview instead of /org/projects (which still redirects to overview) --- .../org-overview-foundations-and-projects.component.html | 4 +++- .../org/org-project-detail/org-project-detail.component.html | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html index 72b978dc3..0736e35cc 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html @@ -103,7 +103,9 @@

0 ? project.projectSlug : project.projectId) - "> + " + (click)="onProjectRowClick(project)" + (keydown)="onProjectRowKeydown($event, project)"> @switch (project.influence) { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 9a5006ded..389d5bbe2 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -38,7 +38,7 @@ @default { @if (hero(); as h) { - + Date: Thu, 25 Jun 2026 03:20:21 +0000 Subject: [PATCH 44/80] =?UTF-8?q?fix(org-lens):=20address=20review=20comme?= =?UTF-8?q?nts=20=E2=80=94=20server=20route,=20handler=20wiring,=20and=20r?= =?UTF-8?q?outing=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ew-foundations-and-projects.component.html | 4 +- .../org-lens-project-detail.service.ts | 28 +++++----- .../org-lens-project-detail.controller.ts | 56 +++++++++++++++++++ apps/lfx-one/src/server/routes/orgs.route.ts | 5 ++ .../org-lens-project-detail.demo-data.ts | 0 .../org-lens-project-detail.service.ts | 20 +++++++ 6 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts rename apps/lfx-one/src/{app/shared => server}/services/org-lens-project-detail.demo-data.ts (100%) create mode 100644 apps/lfx-one/src/server/services/org-lens-project-detail.service.ts diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html index 0736e35cc..72b978dc3 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html @@ -103,9 +103,7 @@

0 ? project.projectSlug : project.projectId) - " - (click)="onProjectRowClick(project)" - (keydown)="onProjectRowKeydown($event, project)"> + "> @switch (project.influence) { diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts index d4bd8b1a0..fae2e67ab 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts @@ -1,30 +1,28 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) - -import { Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; import type { OrgLensProjectDetailResponse } from '@lfx-one/shared/interfaces'; -import { delay, Observable, of } from 'rxjs'; - -import { getDemoProjectDetail } from './org-lens-project-detail.demo-data'; - -/** Simulated network latency so the page exercises its loading skeletons. */ -const DEMO_LATENCY_MS = 450; +import { catchError, Observable, of, throwError } from 'rxjs'; /** - * Data seam for the Org Lens · Project Detail sub-page (LFXV2-1885). - * - * Currently returns demo company fixtures (`null` for an unknown slug → 404 state). Wiring - * the real Snowflake / LFX Insights backend (a separate story) only replaces this method - * body with an `HttpClient` call to `/api/orgs/:orgUid/lens/projects/:projectSlug` — the + * Client-side proxy for GET /api/orgs/:orgUid/lens/projects/:projectSlug. + * Returns `null` when the server responds with 404 (unknown project slug) so the + * page can render its not-found state. Wiring the real Snowflake / LFX Insights + * backend (a separate story) only requires updating the server service — the * response shape and every consumer stay the same. */ @Injectable({ providedIn: 'root', }) export class OrgLensProjectDetailService { + private readonly http = inject(HttpClient); + public getProjectDetail(orgUid: string, orgName: string, projectSlug: string): Observable { - return of(getDemoProjectDetail(orgUid, orgName, projectSlug)).pipe(delay(DEMO_LATENCY_MS)); + const url = `/api/orgs/${encodeURIComponent(orgUid)}/lens/projects/${encodeURIComponent(projectSlug)}`; + return this.http.get(url, { params: { orgName } }).pipe( + catchError((err: HttpErrorResponse) => (err.status === 404 ? of(null) : throwError(() => err))) + ); } } diff --git a/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts b/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts new file mode 100644 index 000000000..7b4b8f98b --- /dev/null +++ b/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts @@ -0,0 +1,56 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { NextFunction, Request, Response } from 'express'; + +import { assertOrgUid } from '../helpers/org-uid.helper'; +import { logger } from '../services/logger.service'; +import { OrgLensProjectDetailService } from '../services/org-lens-project-detail.service'; + +/** HTTP boundary for the Org Lens · Project Detail endpoint — validation, lifecycle logging, error propagation. */ +export class OrgLensProjectDetailController { + private readonly service: OrgLensProjectDetailService; + + public constructor() { + this.service = new OrgLensProjectDetailService(); + } + + /** GET /api/orgs/:orgUid/lens/projects/:projectSlug */ + public getProjectDetail(req: Request, res: Response, next: NextFunction): void { + const orgUid = req.params['orgUid']; + const projectSlug = req.params['projectSlug']; + const orgName = typeof req.query['orgName'] === 'string' ? req.query['orgName'] : 'Your Organization'; + + const startTime = logger.startOperation(req, 'get_org_lens_project_detail', { + org_uid: orgUid, + project_slug: projectSlug, + }); + + try { + assertOrgUid(orgUid, 'get_org_lens_project_detail'); + + const response = this.service.getProjectDetail(orgUid, orgName, projectSlug ?? ''); + + if (response === null) { + logger.success(req, 'get_org_lens_project_detail', startTime, { + org_uid: orgUid, + project_slug: projectSlug, + found: false, + }); + res.status(404).json({ message: 'Project not found' }); + return; + } + + logger.success(req, 'get_org_lens_project_detail', startTime, { + org_uid: orgUid, + project_slug: projectSlug, + found: true, + }); + + res.setHeader('Cache-Control', 'no-store'); + res.json(response); + } catch (error) { + next(error); + } + } +} diff --git a/apps/lfx-one/src/server/routes/orgs.route.ts b/apps/lfx-one/src/server/routes/orgs.route.ts index d662f4673..b6bc3b4a3 100644 --- a/apps/lfx-one/src/server/routes/orgs.route.ts +++ b/apps/lfx-one/src/server/routes/orgs.route.ts @@ -13,6 +13,7 @@ import { OrgLensFoundationsController } from '../controllers/org-lens-foundation import { OrgLensKeyContactsController } from '../controllers/org-lens-key-contacts.controller'; import { OrgLensMembershipsController } from '../controllers/org-lens-memberships.controller'; import { OrgLensPeopleController } from '../controllers/org-lens-people.controller'; +import { OrgLensProjectDetailController } from '../controllers/org-lens-project-detail.controller'; import { OrgLensTrainingController } from '../controllers/org-lens-training.controller'; function buildOrgsRouter(): Router { @@ -27,6 +28,7 @@ function buildOrgsRouter(): Router { const orgLensAccessController = new OrgLensAccessController(); const orgLensTrainingController = new OrgLensTrainingController(); const orgLensContributionsController = new OrgLensContributionsController(); + const orgLensProjectDetailController = new OrgLensProjectDetailController(); const orgIdentityController = new OrgIdentityController(); // Spec 020 — org-selector identity & role-grants endpoints. @@ -116,6 +118,9 @@ function buildOrgsRouter(): Router { // LFXV2-1894 — Org Lens Code Contributions page (KPI strip + repositories table + commits feed). router.get('/:orgUid/lens/contributions', (req, res, next) => orgLensContributionsController.getContributions(req, res, next)); + // LFXV2-1885 — Org Lens Project Detail sub-page. + router.get('/:orgUid/lens/projects/:projectSlug', (req, res, next) => orgLensProjectDetailController.getProjectDetail(req, res, next)); + // Must stay last so specific /uid and /:orgUid/lens routes match first. router.get('/:id', (req, res, next) => orgIdentityController.getCanonicalRecord(req, res, next)); return router; diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts similarity index 100% rename from apps/lfx-one/src/app/shared/services/org-lens-project-detail.demo-data.ts rename to apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.service.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.service.ts new file mode 100644 index 000000000..b5da4ea47 --- /dev/null +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.service.ts @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { OrgLensProjectDetailResponse } from '@lfx-one/shared/interfaces'; + +import { getDemoProjectDetail } from './org-lens-project-detail.demo-data'; + +/** + * Server-side data seam for the Org Lens · Project Detail sub-page (LFXV2-1885). + * + * Currently serves demo company fixtures keyed by `projectSlug` (`null` → 404). + * Wiring the real Snowflake / LFX Insights backend (a separate story) only replaces + * this method body with actual Snowflake queries — the response shape and every + * consumer stay the same. + */ +export class OrgLensProjectDetailService { + public getProjectDetail(orgUid: string, orgName: string, projectSlug: string): OrgLensProjectDetailResponse | null { + return getDemoProjectDetail(orgUid, orgName, projectSlug); + } +} From 5b6bbcc1ee01c0248cd2a256d6d4d60082b1d51f Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 25 Jun 2026 08:59:52 -0400 Subject: [PATCH 45/80] fix(review): address PR #1028 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer, copilot-swe-agent: - org-project-detail.component.ts: make scoreColumnLabel dynamic using TIME_RANGE_MONTHS[timeRange()] so Activity column shows correct period for 2y/all ranges, not hard-coded (12mo) (per copilot-pull-request-reviewer) - org-project-detail.component.ts: fix All others stacked trend to use the SUM of scores, not average — average understates the aggregate (per copilot-swe-agent) - org-overview-foundations-and-projects.component.html: restore project row click/keydown handlers now that the detail drilldown page is built; remove the deferred-until-ready comment (per copilot-pull-request-reviewer) - org-lens-project-detail.service.ts: remove Generated-with comment (per copilot-pull-request-reviewer) - e2e/org-project-detail.spec.ts: move project-detail-trend-group assertion to the leaderboards test where it is actually rendered (per copilot-swe-agent) - e2e/org-project-detail.spec.ts: remove stale ?score=/ from JSDoc — only ?metric= is used (per copilot-swe-agent) Resolves 5 review threads; 2 threads addressed via responses only (breadcrumb/CTA route and SSR endpoint are intentional by design). Signed-off-by: daniel qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 7 +++++-- .../org-project-detail/org-project-detail.component.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index 88e87b465..f4f7c84ea 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -5,8 +5,10 @@ * Org Lens · Project Detail Page E2E Tests (LFXV2-1885) * * Covers the acceptance criteria for the per-project Org Lens view: - * - leaderboard ranking, metric toggle with ?metric= persistence, - * Activity Count hiding the Band column + * - data-testid resolution smoke test (breadcrumb, hero, tabs, card groups, trend) + * - tab strip switching (click + keyboard) with ?tab= URL persistence + * - leaderboard ranking, score/metric toggles with ?metric= persistence, + * Activity Count hiding the Trend + Band columns, and Show more pagination * - not-found (404) panel for an unknown slug * * Prerequisites: @@ -94,6 +96,7 @@ test.describe('Org Project Detail — leaderboards', () => { await expect(page.getByTestId('project-detail-leaderboard-ecosystem')).toBeVisible(); await expect(page.getByTestId('project-detail-leaderboard-technical-viewing-row')).toBeVisible(); await expect(page.getByTestId('project-detail-leaderboard-ecosystem-viewing-row')).toBeVisible(); + await expect(page.getByTestId('project-detail-trend-group')).toBeVisible(); }); test('metric toggle persists in the URL and switches the score column', async ({ page }) => { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 2419bae49..03d41c84f 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -247,7 +247,10 @@ export class OrgProjectDetailComponent { protected readonly metric = computed(() => this.initMetric()); protected readonly timeRange = computed(() => this.initTimeRange()); protected readonly isActivityMode = computed(() => this.metric() === 'activity'); - protected readonly scoreColumnLabel = computed(() => (this.isActivityMode() ? 'Activity (12mo)' : 'Influence Score')); + protected readonly scoreColumnLabel = computed(() => { + if (!this.isActivityMode()) return 'Influence Score'; + return `Activity (${TIME_RANGE_MONTHS[this.timeRange()]}mo)`; + }); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); @@ -641,8 +644,8 @@ export class OrgProjectDetailComponent { } const entries: StackEntry[] = top10.map((r, i) => ({ name: r.orgName, score: r.scores.combined, seed: i })); if (rest.length > 0) { - const avg = rest.reduce((s, r) => s + r.scores.combined, 0) / rest.length; - entries.push({ name: 'All others', score: avg, seed: 10 }); + const restScore = rest.reduce((s, r) => s + r.scores.combined, 0); + entries.push({ name: 'All others', score: restScore, seed: 10 }); } // Build raw series, then normalize each month so all series sum to 100%. From 5e4f39b8306ec6629a3bfb1c2509cc625e5b774a Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 07:17:00 -0600 Subject: [PATCH 46/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 389d5bbe2..14e513821 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -441,8 +441,8 @@

Influence Trend

}

-

{{ card.caption.emphasis }}

-

{{ card.statLabel }}

+

{{ card.caption.emphasis || '—' }}

+

{{ card.statLabel || card.caption.prefix }}

From 6ff84af84e6f29530e8268d901eca553b9b859fa Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 07:17:17 -0600 Subject: [PATCH 47/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org-project-detail.component.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 03d41c84f..7b3ecbd54 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -500,16 +500,22 @@ export class OrgProjectDetailComponent { tip.style.top = `${rect.top + tooltip.caretY}px`; tip.style.transform = 'translateY(-100%)'; - const title = tooltip.title?.[0] ?? ''; + const esc = (value: string) => + value.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch] ?? ch)); + + const title = esc(tooltip.title?.[0] ?? ''); const rows = (tooltip.dataPoints ?? []) - .map( - (p) => + .map((p) => { + const label = esc(p.dataset.label ?? ''); + const formatted = esc(`${p.formattedValue}${valueSuffix}`); + return ( '
' + `` + - `${p.dataset.label ?? ''}: ` + - `${p.formattedValue}${valueSuffix}` + + `${label}: ` + + `${formatted}` + '
' - ) + ); + }) .join(''); tip.innerHTML = `

${title}

${rows}`; From 6dec5a967c4b610102a2ee0b413e3d57e8ae7acb Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 07:17:28 -0600 Subject: [PATCH 48/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 7b3ecbd54..46e224a0b 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -369,9 +369,10 @@ export class OrgProjectDetailComponent { return raw && VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : DEFAULT_TIME_RANGE; } - /** Per-dimension activity count (12mo) derived from that dimension's score, so the two boards differ. */ + /** Per-dimension activity count derived from that dimension's score, so the two boards differ. */ private activityFor(score: number): number { - return Math.round(score * 46); + const months = TIME_RANGE_MONTHS[this.timeRange()]; + return Math.round(score * 46 * (months / 12)); } /** From 4b56539406f1c52d73150bf4afb843aef15fbfab Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 25 Jun 2026 09:37:12 -0400 Subject: [PATCH 49/80] fix(review): address PR #1028 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer, coderabbitai[bot]: - org-lens-project-detail.interface.ts: remove stale ?score= reference from OrgLensScoreType JSDoc (type reserved for a future toggle, not yet wired as a URL param); change OrgLensLeaderboardMetric comment from "Trend + Band columns" to "Band column" — the leaderboard has no separate Trend column (per copilot-pull-request-reviewer) - org-project-detail.component.ts: filter orgUid$ in initDetailStream() so combineLatest only emits when a real uid is available, preventing a hard-coded 'demo-org' fallback from hitting assertOrgUid on the BFF and causing a 4xx + fetchError during initial hydration (per copilot-pull-request-reviewer); Prettier formatting applied - org-lens-project-detail.service.ts: Prettier formatting applied (per coderabbitai[bot]) Resolves 4 review threads; 1 thread responded to as false positive (component.ts format:check — file is already clean). Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 4 ++-- .../app/shared/services/org-lens-project-detail.service.ts | 6 +++--- .../src/interfaces/org-lens-project-detail.interface.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 46e224a0b..9894f36f7 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -405,8 +405,8 @@ export class OrgProjectDetailComponent { } private initDetailStream(): Observable { - const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid ?? 'demo-org')); - const orgName$ = toObservable(computed(() => this.accountContext.selectedAccount()?.accountName ?? 'Acme Corp')); + const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid)).pipe(filter((uid): uid is string => !!uid)); + const orgName$ = toObservable(computed(() => this.accountContext.selectedAccount()?.accountName ?? '')); const projectSlug$ = this.route.paramMap.pipe(map((params) => params.get('projectSlug'))); const retryTrigger$ = toObservable(this.retryTrigger); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts index fae2e67ab..d01a33498 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts @@ -21,8 +21,8 @@ export class OrgLensProjectDetailService { public getProjectDetail(orgUid: string, orgName: string, projectSlug: string): Observable { const url = `/api/orgs/${encodeURIComponent(orgUid)}/lens/projects/${encodeURIComponent(projectSlug)}`; - return this.http.get(url, { params: { orgName } }).pipe( - catchError((err: HttpErrorResponse) => (err.status === 404 ? of(null) : throwError(() => err))) - ); + return this.http + .get(url, { params: { orgName } }) + .pipe(catchError((err: HttpErrorResponse) => (err.status === 404 ? of(null) : throwError(() => err)))); } } diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index c8d8f61b3..53c9b8f8f 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -16,10 +16,10 @@ export type OrgLensProjectHealth = 'excellent' | 'healthy' | 'at-risk'; /** Leaderboard band per the markup-mu methodology (Boysel et al.). Strongest → weakest. */ export type OrgLensProjectBand = 'leading' | 'contributing' | 'participating' | 'non-lf'; -/** Leaderboard score-type segmented control (`?score=`). */ +/** Leaderboard score-type dimension. Reserved for a future toggle — not yet persisted as a URL param. */ export type OrgLensScoreType = 'combined' | 'technical' | 'ecosystem'; -/** Leaderboard metric toggle (`?metric=`). Activity Count mode hides the Trend + Band columns. */ +/** Leaderboard metric toggle (`?metric=`). Activity Count mode hides the Band column. */ export type OrgLensLeaderboardMetric = 'influence' | 'activity'; /** Time range selector (`?range=`) for the leaderboards tab — affects the stacked trend chart and leaderboard period labels. */ From efc3d5e0ba0f4d12135a4d12edee423f940c1fe4 Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 09:12:35 -0600 Subject: [PATCH 50/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 9894f36f7..a0ad5f9cc 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -263,8 +263,10 @@ export class OrgProjectDetailComponent { // After cards populate (async data load), check whether each track is actually scrollable. private readonly _scrollEffect = effect(() => { + const tab = this.activeTab(); const techLen = this.technicalCards().length; const ecoLen = this.ecosystemCards().length; + if (tab !== 'pd-influence') return; if (techLen === 0 && ecoLen === 0) return; Promise.resolve().then(() => { this.refreshArrows(this.techTrackRef?.nativeElement, true); From 918b5c4b68e14cd7f1f0b271991017234825132c Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 09:12:45 -0600 Subject: [PATCH 51/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 14e513821..6ed25d668 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -38,7 +38,7 @@ @default { @if (hero(); as h) { - + Date: Thu, 25 Jun 2026 09:12:55 -0600 Subject: [PATCH 52/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../src/interfaces/org-lens-project-detail.interface.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index 53c9b8f8f..e04ad2789 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -69,12 +69,9 @@ export interface OrgLensProjectInfluenceCard { } /** One cell in a card-specific detail table row. Exactly one of `person` or `text` should be set. */ -export interface OrgLensCardDetailCell { - /** Renders as avatar initials + name when set. */ - person?: { name: string; avatarUrl?: string }; - /** Plain text value when not a person cell. */ - text?: string; -} +export type OrgLensCardDetailCell = + | { person: { name: string; avatarUrl?: string }; text?: never } + | { text: string; person?: never }; /** One row in the card-specific data table shown in the influence card detail drawer. */ export interface OrgLensCardDetailRow { From e48e9b9bbadb31d6f0b98853b0f4b511425ddb35 Mon Sep 17 00:00:00 2001 From: Dano Qualls Date: Thu, 25 Jun 2026 09:27:08 -0600 Subject: [PATCH 53/80] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dano Qualls --- .../org/org-project-detail/org-project-detail.component.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 6ed25d668..6655b1a8e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -137,6 +137,9 @@

Our Technical Influence

@if (technicalBand(); as band) { +

Our Technical Influence

- @if (technicalBand(); as band) { - + @if (technicalBandMeta(); as meta) { + - {{ bandLabel(band) }} + {{ meta.label }} }
@@ -184,14 +184,14 @@

Our Technical Influence

Our Ecosystem Influence

- @if (ecosystemBand(); as band) { - + @if (ecosystemBandMeta(); as meta) { + - {{ bandLabel(band) }} + {{ meta.label }} }
@@ -527,7 +527,7 @@
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index a0ad5f9cc..e21107d4b 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -13,8 +13,28 @@ import { EmptyStateComponent } from '@components/empty-state/empty-state.compone import { TableComponent } from '@components/table/table.component'; import { TagComponent } from '@components/tag/tag.component'; import { OrgProjectDetailTabBarComponent } from './org-project-detail-tab-bar.component'; -import { lfxColors } from '@lfx-one/shared/constants'; +import { + BAND_CHIP_CLASS, + BAND_SIGNAL_FILL, + BAND_SIGNAL_FILL_LIGHT, + BAND_SIGNAL_RANK, + BAND_TAG, + DEFAULT_METRIC, + PD_DEFAULT_TAB, + PD_VALID_TABS, + DEFAULT_TIME_RANGE, + HEALTH_TAG, + lfxColors, + METRIC_OPTIONS, + STACKED_PALETTE, + TIME_RANGE_MONTHS, + TIME_RANGE_OPTIONS, + VALID_METRICS, + VALID_TIME_RANGES, +} from '@lfx-one/shared/constants'; import type { + InfluenceCardVm, + LeaderboardDimension, OrgLensCardDetailSection, OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, @@ -24,7 +44,6 @@ import type { OrgLensProjectDetailTab, OrgLensProjectHealth, OrgLensProjectInfluenceCard, - TagSeverity, } from '@lfx-one/shared/interfaces'; import { parseLocalDateString } from '@lfx-one/shared/utils'; import type { MenuItem } from 'primeng/api'; @@ -35,106 +54,6 @@ import { TooltipModule } from 'primeng/tooltip'; import type { ChartData, ChartOptions, ChartType } from 'chart.js'; import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; -/** Presentation VM for each influence card — includes chart objects typed for Chart.js. */ -interface InfluenceCardVm { - key: string; - title: string; - scopeLabel: string | null; - hasData: boolean; - chartType: ChartType; - chartData: ChartData; - chartOptions: ChartOptions; - valueSuffix: string; - caption: { prefix: string; emphasis: string; suffix: string }; - statLabel: string; - testId: string; -} - -const DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; -const VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); - -const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; -const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); - -const DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '1y'; -const VALID_TIME_RANGES: ReadonlySet = new Set(['1y', '2y', 'all']); - -/** Dimension keyed by each side-by-side leaderboard. */ -type LeaderboardDimension = 'technical' | 'ecosystem'; - -/** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ -const HEALTH_TAG: Record = { - excellent: { label: 'Excellent', severity: 'success' }, - healthy: { label: 'Healthy', severity: 'warn' }, - 'at-risk': { label: 'At Risk', severity: 'danger' }, -}; - -/** Leaderboard band chip → lfx-tag severity. */ -const BAND_TAG: Record = { - leading: { label: 'Leading', severity: 'success' }, - contributing: { label: 'Contributing', severity: 'info' }, - participating: { label: 'Participating', severity: 'warn' }, - 'non-lf': { label: 'Non-LF', severity: 'secondary' }, -}; - -/** - * Signal-strength bar fill constants for the section-header band chip. - * Inlined here (not in @lfx-one/shared) to avoid conflict with the shared-package additions - * landing with PR #921 (LFXV2-1883). Merge those once that PR lands. - */ -const BAND_SIGNAL_RANK: Record = { - leading: 4, - contributing: 3, - participating: 2, - 'non-lf': 0, -}; -const BAND_SIGNAL_FILL: Record = { - leading: 'fill-emerald-500', - contributing: 'fill-blue-500', - participating: 'fill-amber-500', - 'non-lf': 'fill-gray-400', -}; -const BAND_SIGNAL_FILL_LIGHT: Record = { - leading: 'fill-emerald-200', - contributing: 'fill-blue-200', - participating: 'fill-amber-200', - 'non-lf': 'fill-gray-200', -}; -const BAND_CHIP_CLASS: Record = { - leading: 'inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700', - contributing: 'inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700', - participating: 'inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700', - 'non-lf': 'inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600', -}; - -const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string; icon: string }[] = [ - { id: 'influence', label: 'Calculated Influence', icon: 'fa-light fa-chart-bar' }, - { id: 'activity', label: 'Activity Count', icon: 'fa-light fa-list-ol' }, -]; - -const TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ - { id: '1y', label: '1 year' }, - { id: '2y', label: '2 years' }, - { id: 'all', label: 'All time' }, -]; - -const TIME_RANGE_MONTHS: Record = { '1y': 12, '2y': 24, all: 36 }; - -/** 11-slot palette for the stacked trend chart — top-10 companies + "All others". */ -const STACKED_PALETTE: string[] = [ - lfxColors.blue[600], - lfxColors.blue[400], - lfxColors.emerald[500], - lfxColors.emerald[400], - lfxColors.violet[500], - lfxColors.violet[400], - lfxColors.amber[500], - lfxColors.amber[400], - lfxColors.blue[300], - lfxColors.emerald[300], - lfxColors.gray[400], -]; - /** Band thresholds per Boysel et al. markup-mu (Leading / Contributing / Participating / Non-LF). */ function bandForScore(score: number): OrgLensProjectBand { if (score >= 80) return 'leading'; @@ -225,6 +144,16 @@ export class OrgProjectDetailComponent { const scores = this.viewingScores(); return scores ? bandForScore(scores.ecosystem) : null; }); + protected readonly technicalBandMeta = computed(() => { + const band = this.technicalBand(); + if (!band) return null; + return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: BAND_TAG[band].label }; + }); + protected readonly ecosystemBandMeta = computed(() => { + const band = this.ecosystemBand(); + if (!band) return null; + return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: BAND_TAG[band].label }; + }); // Our Influence tab — Technical + Ecosystem cards (per-card chart type and data). private readonly monthLabels: string[] = this.buildMonthLabels(); @@ -281,7 +210,7 @@ export class OrgProjectDetailComponent { if (this.activeTab() === tab) return; void this.router.navigate([], { relativeTo: this.route, - queryParams: { tab: tab === DEFAULT_TAB ? null : tab }, + queryParams: { tab: tab === PD_DEFAULT_TAB ? null : tab }, queryParamsHandling: 'merge', replaceUrl: true, }); @@ -316,27 +245,6 @@ export class OrgProjectDetailComponent { (dimension === 'technical' ? this.techSearch : this.ecoSearch).set(value); } - protected bandChipClass(band: OrgLensProjectBand): string { - return BAND_CHIP_CLASS[band]; - } - - protected bandLabel(band: OrgLensProjectBand): string { - return BAND_TAG[band].label; - } - - protected bandSignalBars(band: OrgLensProjectBand): { x: number; y: number; h: number; fillClass: string }[] { - const rank = BAND_SIGNAL_RANK[band]; - const heights = [5, 8.3, 11.6, 15]; - const barWidth = 2.6; - const gap = 1.8; - return heights.map((h, i) => ({ - x: i * (barWidth + gap), - y: 15 - h, - h, - fillClass: i < rank ? BAND_SIGNAL_FILL[band] : BAND_SIGNAL_FILL_LIGHT[band], - })); - } - protected openCardDetail(card: InfluenceCardVm): void { this.selectedCard.set(card); this.drawerOpen.set(true); @@ -346,11 +254,6 @@ export class OrgProjectDetailComponent { this.drawerOpen.set(false); } - /** Returns initials for a person name (used in drawer person-cell avatars). */ - protected personInitials(name: string): string { - return this.initialsFor(name); - } - /** Scrolls a card track by one card slot (336 px = w-80 + gap-4). */ protected scrollCards(el: HTMLElement, direction: 1 | -1): void { el.scrollBy({ left: direction * 336, behavior: 'smooth' }); @@ -403,7 +306,7 @@ export class OrgProjectDetailComponent { private initActiveTab(): OrgLensProjectDetailTab { const raw = this.queryParamMap().get('tab'); - return raw && VALID_TABS.has(raw) ? (raw as OrgLensProjectDetailTab) : DEFAULT_TAB; + return raw && PD_VALID_TABS.has(raw) ? (raw as OrgLensProjectDetailTab) : PD_DEFAULT_TAB; } private initDetailStream(): Observable { @@ -471,6 +374,19 @@ export class OrgProjectDetailComponent { }); } + private buildBandBars(band: OrgLensProjectBand): { x: number; y: number; h: number; fillClass: string }[] { + const rank = BAND_SIGNAL_RANK[band]; + const heights = [5, 8.3, 11.6, 15]; + const barWidth = 2.6; + const gap = 1.8; + return heights.map((h, i) => ({ + x: i * (barWidth + gap), + y: 15 - h, + h, + fillClass: i < rank ? BAND_SIGNAL_FILL[band] : BAND_SIGNAL_FILL_LIGHT[band], + })); + } + private initialsFor(name: string): string { const parts = name.split(/[\s/]+/).filter(Boolean); if (parts.length === 0) return '?'; diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 3a2ada071..571048e10 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -239,7 +239,7 @@ function trailing12Months(): string[] { /** Deterministic 12-point ramp ending at `end` (starts ~`startFactor` of it). No RNG → SSR-stable. */ function ramp(end: number, startFactor: number, round = 0): number[] { - if (end === 0) return Array.from({ length: 12 }, () => 0); + if (end === 0) return []; const start = end * startFactor; const step = (end - start) / 11; const factor = 10 ** round; @@ -538,8 +538,16 @@ const FULL_DATES = ['Mar 15, 2024', 'Jun 22, 2024', 'Sep 8, 2024', 'Nov 30, 2024 const DEMO_COMMITTEES = ['Technical Oversight Committee', 'Security TAG', 'Storage TAG', 'App Delivery TAG', 'Runtime TAG']; +function personInitials(name: string): string { + const parts = name.split(/[\s/]+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + function personCell(i: number, pool: string[] = DEMO_PEOPLE): OrgLensCardDetailCell { - return { person: { name: pool[i % pool.length] } }; + const name = pool[i % pool.length]; + return { person: { name, initials: personInitials(name) } }; } function textCell(value: string): OrgLensCardDetailCell { @@ -804,7 +812,7 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu softwareValueUsd: seed.softwareValueUsd, health: seed.health, foundationLabel: seed.foundationLabel, - // Static demo build timestamp (~1h ago) so the freshness label renders a stable relative value. + // Computed per-request as ~1h ago so the freshness label renders a stable relative value in demo. lastUpdated: new Date(Date.now() - 60 * 60 * 1000).toISOString(), }, technical: technicalCards(seed), diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 5ee1f779d..bae1225d2 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -80,3 +80,4 @@ export * from './mktg-os-agents.constants'; export * from './project-context.constants'; export * from './project-staff.constants'; +export * from './org-lens-project-detail.constants'; diff --git a/packages/shared/src/constants/org-lens-project-detail.constants.ts b/packages/shared/src/constants/org-lens-project-detail.constants.ts new file mode 100644 index 000000000..99c6a6867 --- /dev/null +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -0,0 +1,86 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectBand, OrgLensProjectDetailTab, OrgLensProjectHealth } from '../interfaces/org-lens-project-detail.interface'; +import type { TagSeverity } from '../interfaces/components.interface'; +import { lfxColors } from './colors.constants'; + +export const PD_DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; +export const PD_VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); + +export const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; +export const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); + +export const DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '1y'; +export const VALID_TIME_RANGES: ReadonlySet = new Set(['1y', '2y', 'all']); + +/** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ +export const HEALTH_TAG: Record = { + excellent: { label: 'Excellent', severity: 'success' }, + healthy: { label: 'Healthy', severity: 'warn' }, + 'at-risk': { label: 'At Risk', severity: 'danger' }, +}; + +/** Leaderboard band chip → lfx-tag severity. */ +export const BAND_TAG: Record = { + leading: { label: 'Leading', severity: 'success' }, + contributing: { label: 'Contributing', severity: 'info' }, + participating: { label: 'Participating', severity: 'warn' }, + 'non-lf': { label: 'Non-LF', severity: 'secondary' }, +}; + +export const BAND_SIGNAL_RANK: Record = { + leading: 4, + contributing: 3, + participating: 2, + 'non-lf': 0, +}; + +export const BAND_SIGNAL_FILL: Record = { + leading: 'fill-emerald-500', + contributing: 'fill-blue-500', + participating: 'fill-amber-500', + 'non-lf': 'fill-gray-400', +}; + +export const BAND_SIGNAL_FILL_LIGHT: Record = { + leading: 'fill-emerald-200', + contributing: 'fill-blue-200', + participating: 'fill-amber-200', + 'non-lf': 'fill-gray-200', +}; + +export const BAND_CHIP_CLASS: Record = { + leading: 'inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700', + contributing: 'inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700', + participating: 'inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700', + 'non-lf': 'inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600', +}; + +export const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string; icon: string }[] = [ + { id: 'influence', label: 'Calculated Influence', icon: 'fa-light fa-chart-bar' }, + { id: 'activity', label: 'Activity Count', icon: 'fa-light fa-list-ol' }, +]; + +export const TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ + { id: '1y', label: '1 year' }, + { id: '2y', label: '2 years' }, + { id: 'all', label: 'All time' }, +]; + +export const TIME_RANGE_MONTHS: Record = { '1y': 12, '2y': 24, all: 36 }; + +/** 11-slot palette for the stacked trend chart — top-10 companies + "All others". */ +export const STACKED_PALETTE: string[] = [ + lfxColors.blue[600], + lfxColors.blue[400], + lfxColors.emerald[500], + lfxColors.emerald[400], + lfxColors.violet[500], + lfxColors.violet[400], + lfxColors.amber[500], + lfxColors.amber[400], + lfxColors.blue[300], + lfxColors.emerald[300], + lfxColors.gray[400], +]; diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index e04ad2789..0ece86f5c 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -10,6 +10,26 @@ * ship independently of the sibling Projects page (LFXV2-1883 / LFXV2-1884). */ +import type { ChartData, ChartOptions, ChartType } from 'chart.js'; + +/** Dimension keyed by each side-by-side leaderboard. */ +export type LeaderboardDimension = 'technical' | 'ecosystem'; + +/** Presentation VM for each influence card — includes chart objects typed for Chart.js. */ +export interface InfluenceCardVm { + key: string; + title: string; + scopeLabel: string | null; + hasData: boolean; + chartType: ChartType; + chartData: ChartData; + chartOptions: ChartOptions; + valueSuffix: string; + caption: { prefix: string; emphasis: string; suffix: string }; + statLabel: string; + testId: string; +} + /** CHAOSS-derived project health classification — drives the hero badge color token. */ export type OrgLensProjectHealth = 'excellent' | 'healthy' | 'at-risk'; @@ -70,7 +90,7 @@ export interface OrgLensProjectInfluenceCard { /** One cell in a card-specific detail table row. Exactly one of `person` or `text` should be set. */ export type OrgLensCardDetailCell = - | { person: { name: string; avatarUrl?: string }; text?: never } + | { person: { name: string; avatarUrl?: string; initials: string }; text?: never } | { text: string; person?: never }; /** One row in the card-specific data table shown in the influence card detail drawer. */ From 49e06905e0d19c7ce5a058c5d60ef0719ea8acd7 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 29 Jun 2026 11:10:30 -0400 Subject: [PATCH 57/80] style(org-lens): fix prettier formatting on project detail files Run yarn format on files that were flagged by the CI format:check step: - org-project-detail.component.ts - org-lens-project-detail.constants.ts - org-lens-project-detail.interface.ts LFXV2-1885 Signed-off-by: daniel qualls --- .../org-project-detail/org-project-detail.component.ts | 3 +-- .../src/constants/org-lens-project-detail.constants.ts | 8 +++++++- .../src/interfaces/org-lens-project-detail.interface.ts | 4 +--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index e21107d4b..006f2c8f1 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -419,8 +419,7 @@ export class OrgProjectDetailComponent { tip.style.top = `${rect.top + tooltip.caretY}px`; tip.style.transform = 'translateY(-100%)'; - const esc = (value: string) => - value.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch] ?? ch)); + const esc = (value: string) => value.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] ?? ch); const title = esc(tooltip.title?.[0] ?? ''); const rows = (tooltip.dataPoints ?? []) diff --git a/packages/shared/src/constants/org-lens-project-detail.constants.ts b/packages/shared/src/constants/org-lens-project-detail.constants.ts index 99c6a6867..e8e19ed3c 100644 --- a/packages/shared/src/constants/org-lens-project-detail.constants.ts +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -1,7 +1,13 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectBand, OrgLensProjectDetailTab, OrgLensProjectHealth } from '../interfaces/org-lens-project-detail.interface'; +import type { + OrgLensLeaderboardMetric, + OrgLensLeaderboardTimeRange, + OrgLensProjectBand, + OrgLensProjectDetailTab, + OrgLensProjectHealth, +} from '../interfaces/org-lens-project-detail.interface'; import type { TagSeverity } from '../interfaces/components.interface'; import { lfxColors } from './colors.constants'; diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index 0ece86f5c..c09f4fee4 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -89,9 +89,7 @@ export interface OrgLensProjectInfluenceCard { } /** One cell in a card-specific detail table row. Exactly one of `person` or `text` should be set. */ -export type OrgLensCardDetailCell = - | { person: { name: string; avatarUrl?: string; initials: string }; text?: never } - | { text: string; person?: never }; +export type OrgLensCardDetailCell = { person: { name: string; avatarUrl?: string; initials: string }; text?: never } | { text: string; person?: never }; /** One row in the card-specific data table shown in the influence card detail drawer. */ export interface OrgLensCardDetailRow { From 2c1cadd40dcb2cb3b759693817be38ccf681e81a Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 29 Jun 2026 11:20:12 -0400 Subject: [PATCH 58/80] fix(org-lens): remove unused OrgLensProjectHealth import Removes the unused OrgLensProjectHealth import from org-project-detail component that was causing the lint:check CI failure. Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 006f2c8f1..a724bb06a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -42,7 +42,6 @@ import type { OrgLensProjectDetailPageState, OrgLensProjectDetailResponse, OrgLensProjectDetailTab, - OrgLensProjectHealth, OrgLensProjectInfluenceCard, } from '@lfx-one/shared/interfaces'; import { parseLocalDateString } from '@lfx-one/shared/utils'; From 74f99feb95773b27bdab8049825e2ff15355c55c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:22:51 +0000 Subject: [PATCH 59/80] fix(org-lens): address review 4593147828 findings - Use barrel import in org-lens-project-detail.constants.ts instead of deep relative paths to ../interfaces/*.interface files - Guard trendSeries() with ?? 0 fallback so ramp() returning [] for zero-influence projects doesn't emit undefined values that violate OrgLensProjectTrendPoint contract - Guard deltaFromSparkline() against empty series arrays to prevent NaN in trendDeltaPct when viewing org has zero influence score - Remove dead lineCardOptions/barCardOptions class properties from OrgProjectDetailComponent; toInfluenceCard() already builds the correct per-card options with valueSuffix via buildBarCardOptions() and buildLineAreaCardOptions()" --- .../org/org-project-detail/org-project-detail.component.ts | 4 ---- .../src/server/services/org-lens-project-detail.demo-data.ts | 3 ++- .../shared/src/constants/org-lens-project-detail.constants.ts | 4 ++-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index a724bb06a..9bcea96c7 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -165,10 +165,6 @@ export class OrgProjectDetailComponent { return (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem', months)); }); - // Sparkline card options — stable class-level references so Angular passes them correctly through ng-template context. - protected readonly lineCardOptions: ChartOptions = this.buildLineAreaCardOptions(); - protected readonly barCardOptions: ChartOptions = this.buildBarCardOptions(); - // Leaderboards tab — URL-persisted metric toggle + time range + two side-by-side boards + stacked trend. protected readonly metricOptions = METRIC_OPTIONS; protected readonly timeRangeOptions = TIME_RANGE_OPTIONS; diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 571048e10..e49710a25 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -419,7 +419,7 @@ function trendSeries(seed: ProjectDetailSeed): OrgLensProjectTrendPoint[] { const combined = ramp(seed.influence.combined, 0.83, 1); const technical = ramp(seed.influence.technical, 0.85, 1); const ecosystem = ramp(seed.influence.ecosystem, 0.8, 1); - return months.map((month, i) => ({ month, combined: combined[i], technical: technical[i], ecosystem: ecosystem[i] })); + return months.map((month, i) => ({ month, combined: combined[i] ?? 0, technical: technical[i] ?? 0, ecosystem: ecosystem[i] ?? 0 })); } /** @@ -486,6 +486,7 @@ function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLe } function deltaFromSparkline(series: number[]): number { + if (series.length === 0) return 0; const start = series[0]; return start === 0 ? 0 : Math.round(((series[series.length - 1] - start) / start) * 100) / 100; } diff --git a/packages/shared/src/constants/org-lens-project-detail.constants.ts b/packages/shared/src/constants/org-lens-project-detail.constants.ts index e8e19ed3c..f1a891e03 100644 --- a/packages/shared/src/constants/org-lens-project-detail.constants.ts +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -7,8 +7,8 @@ import type { OrgLensProjectBand, OrgLensProjectDetailTab, OrgLensProjectHealth, -} from '../interfaces/org-lens-project-detail.interface'; -import type { TagSeverity } from '../interfaces/components.interface'; + TagSeverity, +} from '../interfaces'; import { lfxColors } from './colors.constants'; export const PD_DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; From 8b0803c7ab66451e0987b8cac7b7cf49d453af43 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 29 Jun 2026 18:30:51 -0400 Subject: [PATCH 60/80] fix(org-lens): address PR #1028 review feedback (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer: - controller.ts: treat empty/whitespace orgName as missing, fall back to 'Your Organization' (per copilot-pull-request-reviewer) - demo-data.ts: guard avg-merge-time sparkline with org.prs===0?[] so the jenkins (zero-involvement) seed no longer shows false chart data (per copilot-pull-request-reviewer) - demo-data.ts: use ??0 in trendSeries() so projects with zero influence emit valid numeric 0s instead of undefined when ramp() returns [] (per copilot-pull-request-reviewer) - demo-data.ts: guard deltaFromSparkline() against empty series to prevent NaN in trendDeltaPct for zero-score orgs (per copilot-pull-request-reviewer) - component.ts: remove dead lineCardOptions/barCardOptions class-level fields; toInfluenceCard() already calls the builders directly (per copilot-pull-request-reviewer) - component.html: replace [class.invisible] with [disabled] on all 4 scroll buttons so hidden arrows leave the tab order (a11y fix) (per copilot-pull-request-reviewer) - e2e/org-project-detail.spec.ts: add ?range= URL persistence test — switch to 2y, assert URL contains range=2y and column shows Activity (24mo) (per copilot-pull-request-reviewer) Resolves 10 review threads. Signed-off-by: daniel qualls --- apps/lfx-one/e2e/org-project-detail.spec.ts | 7 +++++++ .../org-project-detail.component.html | 16 ++++++++-------- .../org-lens-project-detail.controller.ts | 3 ++- .../org-lens-project-detail.demo-data.ts | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts index 3a61e1fb7..78e891ff0 100644 --- a/apps/lfx-one/e2e/org-project-detail.spec.ts +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -107,6 +107,13 @@ test.describe('Org Project Detail — leaderboards', () => { await expect(page.getByRole('columnheader', { name: 'Influence Score' })).toHaveCount(0); }); + test('time-range toggle persists in the URL and updates the activity column label', async ({ page }) => { + await page.getByTestId('project-detail-metric-activity').click(); + await page.getByTestId('project-detail-time-range-2y').click(); + await expect(page).toHaveURL(/range=2y/); + await expect(page.getByRole('columnheader', { name: 'Activity (24mo)' }).first()).toBeVisible(); + }); + test('search filters a board to matching organizations', async ({ page }) => { await page.getByTestId('project-detail-search-technical').fill('Google'); const rows = page.locator('[data-testid="project-detail-leaderboard-technical"] tbody tr'); diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 8a130f12a..3f847a217 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -152,16 +152,16 @@

Our Technical Influence

{{ card.title }}

-

Last 12 months · {{ card.scopeLabel ?? 'All projects' }}

+

{{ drawerTimeRangeLabel() }} · {{ card.scopeLabel ?? 'All projects' }}

} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 811ec8920..6e800c52a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -175,6 +175,7 @@ export class OrgProjectDetailComponent { if (!this.isActivityMode()) return 'Influence Score'; return `Activity (${TIME_RANGE_MONTHS[this.timeRange()]}mo)`; }); + protected readonly drawerTimeRangeLabel = computed(() => `Last ${TIME_RANGE_MONTHS[this.timeRange()]} months`); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); From 33006545e6ef9bc65e1da35974290a7303ca0fb8 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 30 Jun 2026 09:45:23 -0400 Subject: [PATCH 66/80] fix(review): address PR #1028 review feedback (round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer: - org-project-detail.component.ts: replace activityFor() synthetic formula with entry.row.activityCount from the wire contract in buildBoard() — the formula (score * 46 * months/12) was reading the dimensional score instead of combined and applying a scaling factor not present in the payload, diverging from OrgLensProjectLeaderboardRow .activityCount which is already computed on the server (per copilot-pull-request-reviewer, lines 277 and 293) - org-project-detail.component.ts: remove the now-unused activityFor() private method Resolves 2 review threads. Signed-off-by: daniel qualls --- .../org-project-detail/org-project-detail.component.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 6e800c52a..ca0aec509 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -270,12 +270,6 @@ export class OrgProjectDetailComponent { return raw && VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : DEFAULT_TIME_RANGE; } - /** Per-dimension activity count derived from that dimension's score, so the two boards differ. */ - private activityFor(score: number): number { - const months = TIME_RANGE_MONTHS[this.timeRange()]; - return Math.round(score * 46 * (months / 12)); - } - /** * Rank the leaderboard for one dimension (technical / ecosystem), then apply the search filter. * Returns all matching rows — the paginator handles slicing. @@ -290,7 +284,7 @@ export class OrgProjectDetailComponent { orgName: entry.row.orgName, orgLogoUrl: entry.row.orgLogoUrl, initials: this.initialsFor(entry.row.orgName), - activityLabel: this.activityFor(entry.score).toLocaleString(), + activityLabel: entry.row.activityCount.toLocaleString(), bandLabel: bandMeta.label, bandSeverity: bandMeta.severity, isViewingOrg: entry.row.isViewingOrg, From 8aef9325a534e02979c3c065c0bf75f830b10478 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 30 Jun 2026 10:43:25 -0400 Subject: [PATCH 67/80] style(org-lens): apply prettier formatting to project detail component files Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 12 ++---------- .../org-project-detail.component.ts | 4 +--- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 23cf2e7b2..aacf69808 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -282,11 +282,7 @@

Our Ecosystem Influence

@if (row.orgLogoUrl) { - + } @else { Our Ecosystem Influence

@if (row.orgLogoUrl) { - + } @else { this.buildTrendSeries(entry.score, months, entry.seed)); const monthTotals = rawSeries[0].map((_, mi) => rawSeries.reduce((sum, s) => sum + (s[mi] ?? 0), 0)); - const pctSeries: number[][] = rawSeries.map((series) => - series.map((val, mi) => (monthTotals[mi] > 0 ? (val / monthTotals[mi]) * 100 : 0)) - ); + const pctSeries: number[][] = rawSeries.map((series) => series.map((val, mi) => (monthTotals[mi] > 0 ? (val / monthTotals[mi]) * 100 : 0))); // Rank by most-recent-month share (most influential now → first in ranked[]). const lastIdx = months - 1; From 895a694c5fe11fdc24b3c8d34e665e780b61f125 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Tue, 30 Jun 2026 10:50:28 -0400 Subject: [PATCH 68/80] fix(review): address PR #1028 review feedback (round 5) Address review comments from copilot-pull-request-reviewer: - org-project-detail.component.ts: removed unused `sourceUrl` computed signal (per copilot-pull-request-reviewer) Resolves 1 review thread. Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 9314a6759..a5793238a 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -131,7 +131,6 @@ export class OrgProjectDetailComponent { protected readonly firstCommitLabel = computed(() => this.formatMonthYear(this.hero()?.firstCommit ?? null)); protected readonly softwareValueLabel = computed(() => this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); protected readonly logoInitials = computed(() => this.initialsFor(this.hero()?.projectName ?? '')); - protected readonly sourceUrl = computed(() => this.hero()?.sourceUrl ?? null); // Org's own influence standing (from its leaderboard row) → section-title band badges. private readonly viewingScores = computed(() => this.detail()?.leaderboard.find((row) => row.isViewingOrg)?.scores ?? null); From 0eae6dd416601ce255479864dde1541dd2c3d454 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 1 Jul 2026 15:10:35 -0400 Subject: [PATCH 69/80] fix(review): address PR #1028 review feedback (round 6) Address review comments from @luismoriguerra and @MRashad26: - org-project-detail.component.ts: fix buildBoard() to sort by activityCount when metric=activity; band fields nulled in activity mode (per @luismoriguerra) - org-project-detail.component.ts: replace @ViewChild decorators with viewChild() signal API (per @MRashad26) - org-project-detail.component.ts: replace banned effect() with toObservable + takeUntilDestroyed() pipeline in constructor (per @MRashad26) - org-project-detail.component.ts: add techSearchHasQuery / ecoSearchHasQuery computed signals (per @luismoriguerra) - org-project-detail.component.html: replace techSearch().trim() and ecoSearch().trim() template method calls with the new computed signals (per @luismoriguerra) - org-project-detail-tab-bar.component.ts: replace @ViewChildren + QueryList with viewChildren() signal API (per @MRashad26) - org-overview-foundations-and-projects.component.ts: add slug fallback (projectSlug || projectId) in navigation (per @luismoriguerra) Resolves 6 review threads. Signed-off-by: daniel qualls --- ...view-foundations-and-projects.component.ts | 2 +- .../org-project-detail-tab-bar.component.ts | 6 +- .../org-project-detail.component.html | 4 +- .../org-project-detail.component.ts | 58 ++++++++++++------- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts index 3413f8560..89a8bdb24 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts @@ -128,7 +128,7 @@ export class OrgOverviewFoundationsAndProjectsComponent { protected onProjectRowClick(project: OrgLensFoundationRow['projects'][number]): void { if (!project.isLfProject) return; this.onProjectClick({ projectId: project.projectId, projectName: project.projectName }); - void this.router.navigate(['/org/projects', project.projectSlug]); + void this.router.navigate(['/org/projects', project.projectSlug || project.projectId]); } protected onProjectRowKeydown(event: KeyboardEvent, project: OrgLensFoundationRow['projects'][number]): void { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts index 285f1b93a..2da3bd30e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { isPlatformBrowser } from '@angular/common'; -import { Component, ElementRef, inject, input, output, PLATFORM_ID, QueryList, ViewChildren } from '@angular/core'; +import { Component, ElementRef, inject, input, output, PLATFORM_ID, viewChildren } from '@angular/core'; import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; @Component({ @@ -14,7 +14,7 @@ import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProj templateUrl: './org-project-detail-tab-bar.component.html', }) export class OrgProjectDetailTabBarComponent { - @ViewChildren('tabBtn') private readonly tabBtns!: QueryList>; + private readonly tabBtns = viewChildren>('tabBtn'); private readonly platformId = inject(PLATFORM_ID); @@ -42,7 +42,7 @@ export class OrgProjectDetailTabBarComponent { this.tabChange.emit(ids[next]); if (isPlatformBrowser(this.platformId)) { const n = next; - setTimeout(() => this.tabBtns.get(n)?.nativeElement.focus()); + setTimeout(() => this.tabBtns()[n]?.nativeElement.focus()); } } } diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index aacf69808..6d9db4950 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -253,7 +253,7 @@

Our Ecosystem Influence

@if (technicalBoard().length === 0) {

- {{ techSearch().trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} + {{ techSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }}

} @else { Our Ecosystem Influence

@if (ecosystemBoard().length === 0) {

- {{ ecoSearch().trim() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} + {{ ecoSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }}

} @else { ; - @ViewChild('ecosystemTrack') private ecoTrackRef?: ElementRef; + private readonly techTrackRef = viewChild>('technicalTrack'); + private readonly ecoTrackRef = viewChild>('ecosystemTrack'); protected readonly accountContext = inject(AccountContextService); private readonly detailService = inject(OrgLensProjectDetailService); @@ -177,6 +177,8 @@ export class OrgProjectDetailComponent { protected readonly drawerTimeRangeLabel = computed(() => `Last ${TIME_RANGE_MONTHS[this.timeRange()]} months`); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); + protected readonly techSearchHasQuery = computed(() => this.techSearch().trim().length > 0); + protected readonly ecoSearchHasQuery = computed(() => this.ecoSearch().trim().length > 0); protected readonly technicalBoard = computed(() => this.buildBoard('technical', this.techSearch())); protected readonly ecosystemBoard = computed(() => this.buildBoard('ecosystem', this.ecoSearch())); @@ -185,22 +187,29 @@ export class OrgProjectDetailComponent { protected readonly stackedTrendData = computed>(() => this.buildStackedTrend()); protected readonly stackedTrendOptions: ChartOptions = this.buildStackedTrendOptions(); - // After cards populate (async data load), check whether each track is actually scrollable. - private readonly _scrollEffect = effect(() => { - const tab = this.activeTab(); - const techLen = this.technicalCards().length; - const ecoLen = this.ecosystemCards().length; - if (tab !== 'pd-influence') return; - if (techLen === 0 && ecoLen === 0) return; - Promise.resolve().then(() => { - this.refreshArrows(this.techTrackRef?.nativeElement, true); - this.refreshArrows(this.ecoTrackRef?.nativeElement, false); - }); - }); - // Subscribe via toSignal so the fetch stream runs; results are mirrored into the signals read by the template. protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); + constructor() { + // React when tab or card counts change to refresh horizontal scroll arrows. + toObservable( + computed(() => ({ + tab: this.activeTab(), + techLen: this.technicalCards().length, + ecoLen: this.ecosystemCards().length, + })) + ) + .pipe( + filter(({ tab, techLen, ecoLen }) => tab === 'pd-influence' && (techLen > 0 || ecoLen > 0)), + switchMap(() => Promise.resolve()), + takeUntilDestroyed() + ) + .subscribe(() => { + this.refreshArrows(this.techTrackRef()?.nativeElement, true); + this.refreshArrows(this.ecoTrackRef()?.nativeElement, false); + }); + } + protected switchTab(tab: OrgLensProjectDetailTab): void { if (this.activeTab() === tab) return; void this.router.navigate([], { @@ -274,18 +283,23 @@ export class OrgProjectDetailComponent { * Returns all matching rows — the paginator handles slicing. */ private buildBoard(dimension: LeaderboardDimension, search: string) { - const valued = (this.detail()?.leaderboard ?? []).map((row) => ({ row, score: row.scores[dimension] })); - valued.sort((a, b) => b.score - a.score || a.row.orgName.localeCompare(b.row.orgName)); + const isActivity = this.metric() === 'activity'; + const valued = (this.detail()?.leaderboard ?? []).map((row) => ({ + row, + score: row.scores[dimension], + sortKey: isActivity ? row.activityCount : row.scores[dimension], + })); + valued.sort((a, b) => b.sortKey - a.sortKey || a.row.orgName.localeCompare(b.row.orgName)); const ranked = valued.map((entry, i) => { - const bandMeta = BAND_TAG[bandForScore(entry.score)]; + const bandMeta = isActivity ? null : BAND_TAG[bandForScore(entry.score)]; return { rank: i + 1, orgName: entry.row.orgName, orgLogoUrl: entry.row.orgLogoUrl, initials: this.initialsFor(entry.row.orgName), activityLabel: entry.row.activityCount.toLocaleString(), - bandLabel: bandMeta.label, - bandSeverity: bandMeta.severity, + bandLabel: bandMeta?.label ?? '', + bandSeverity: bandMeta?.severity ?? 'secondary', isViewingOrg: entry.row.isViewingOrg, }; }); From 1c3ad78bacf6b761d2a3e01ba7115c5a0993ea0d Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 2 Jul 2026 15:31:35 -0400 Subject: [PATCH 70/80] fix(review): guard SEEDS lookup against prototype pollution Address review comment from copilot-pull-request-reviewer: - org-lens-project-detail.demo-data.ts: use Object.hasOwn(SEEDS, projectSlug) before indexing SEEDS, so keys inherited from Object.prototype (constructor, toString, valueOf, etc.) correctly fall through to the not-found path instead of returning a truthy prototype value. Resolves 1 review thread. Signed-off-by: daniel qualls --- .../src/server/services/org-lens-project-detail.demo-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 3d23d0bf8..76d241f05 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -796,8 +796,8 @@ function buildCardDetails(seed: ProjectDetailSeed): Record Date: Thu, 2 Jul 2026 15:39:22 -0400 Subject: [PATCH 71/80] fix(review): point project detail breadcrumb/CTA to Projects list Address review comments from copilot-pull-request-reviewer: - org-project-detail.component.ts: breadcrumb root now labeled "Projects" and links to /org/projects instead of /org/overview - org-project-detail.component.html: not-found CTA and breadcrumb home routerLink now point to /org/projects instead of /org/overview Matches the page's location as a sub-page of /org/projects/:projectSlug. Resolves 3 review threads. Signed-off-by: daniel qualls --- .../org-project-detail/org-project-detail.component.html | 8 ++++---- .../org-project-detail/org-project-detail.component.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index 6d9db4950..f6ddd6b7e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -29,19 +29,19 @@ icon="fa-light fa-folder-open" title="Project not found" subtitle="We couldn't find that project for your organization." - ctaLabel="Back to Org Overview" + ctaLabel="Back to Projects" ctaIcon="fa-light fa-arrow-left" - [ctaRoute]="['/org/overview']" + [ctaRoute]="['/org/projects']" data-testid="project-detail-not-found"> } @default { @if (hero(); as h) { - + diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index f43c37b12..2b8a81ea6 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -351,7 +351,7 @@ export class OrgProjectDetailComponent { private initBreadcrumb(): MenuItem[] { const hero = this.hero(); - const root: MenuItem = { label: 'Org Overview', routerLink: ['/org/overview'] }; + const root: MenuItem = { label: 'Projects', routerLink: ['/org/projects'] }; return hero ? [root, { label: hero.projectName }] : [root]; } From 2a44667d45cd9d214944ef70d4efd9b5cbe961e8 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 2 Jul 2026 17:30:27 -0400 Subject: [PATCH 72/80] fix(review): scope constants, drop dead hero/trend fields, fix stale doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer: - org-lens-project-detail.constants.ts: prefix DEFAULT_METRIC, VALID_METRICS, DEFAULT_TIME_RANGE, VALID_TIME_RANGES, HEALTH_TAG, BAND_TAG, METRIC_OPTIONS, TIME_RANGE_OPTIONS, TIME_RANGE_MONTHS, STACKED_PALETTE with PD_ so the shared barrel export can't collide with an identically-named constant from another feature - org-lens-project-detail.interface.ts, org-lens-project-detail.demo-data.ts: drop hero.sourceUrl, hero.sourceLabel, hero.lastUpdated, and the unused `trend` payload (OrgLensProjectTrendPoint + trendSeries()/trailing12Months() generators) — none are read by the component, which builds its trend chart from `leaderboard` via buildStackedTrend() - org-project-detail.component.ts: corrected the class doc comment — the Projects table and Influence Summary cards are not yet wired to this route; only the Org Overview Foundations & Projects table is Resolves 3 review threads. Signed-off-by: daniel qualls --- .../org-project-detail.component.ts | 60 ++++++++++--------- .../org-lens-project-detail.demo-data.ts | 40 ------------- .../org-lens-project-detail.constants.ts | 20 +++---- .../org-lens-project-detail.interface.ts | 16 ----- 4 files changed, 41 insertions(+), 95 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 2b8a81ea6..ede25c45e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -18,19 +18,19 @@ import { BAND_SIGNAL_FILL, BAND_SIGNAL_FILL_LIGHT, BAND_SIGNAL_RANK, - BAND_TAG, - DEFAULT_METRIC, + PD_BAND_TAG, + PD_DEFAULT_METRIC, PD_DEFAULT_TAB, PD_VALID_TABS, - DEFAULT_TIME_RANGE, - HEALTH_TAG, + PD_DEFAULT_TIME_RANGE, + PD_HEALTH_TAG, lfxColors, - METRIC_OPTIONS, - STACKED_PALETTE, - TIME_RANGE_MONTHS, - TIME_RANGE_OPTIONS, - VALID_METRICS, - VALID_TIME_RANGES, + PD_METRIC_OPTIONS, + PD_STACKED_PALETTE, + PD_TIME_RANGE_MONTHS, + PD_TIME_RANGE_OPTIONS, + PD_VALID_METRICS, + PD_VALID_TIME_RANGES, } from '@lfx-one/shared/constants'; import type { InfluenceCardVm, @@ -62,9 +62,11 @@ function bandForScore(score: number): OrgLensProjectBand { } /** - * Org Lens · Project Detail sub-page (LFXV2-1885). Opened from the Projects table / - * Influence Summary cards via `/org/projects/:projectSlug`. Owns the fetch keyed on the - * selected org + slug, the page-state machine, and the URL-persisted tab strip. + * Org Lens · Project Detail sub-page (LFXV2-1885), routed at `/org/projects/:projectSlug`. + * Currently opened only from the Org Overview Foundations & Projects table; the standalone + * Projects table and Influence Summary cards are wired to this route in a later story. Owns + * the fetch keyed on the selected org + slug, the page-state machine, and the URL-persisted + * tab strip. */ @Component({ selector: 'lfx-org-project-detail', @@ -126,7 +128,7 @@ export class OrgProjectDetailComponent { protected readonly breadcrumbItems = computed(() => this.initBreadcrumb()); protected readonly healthMeta = computed(() => { const health = this.hero()?.health; - return health ? HEALTH_TAG[health] : null; + return health ? PD_HEALTH_TAG[health] : null; }); protected readonly firstCommitLabel = computed(() => this.formatMonthYear(this.hero()?.firstCommit ?? null)); protected readonly softwareValueLabel = computed(() => this.formatCompactUsd(this.hero()?.softwareValueUsd ?? null)); @@ -145,36 +147,36 @@ export class OrgProjectDetailComponent { protected readonly technicalBandMeta = computed(() => { const band = this.technicalBand(); if (!band) return null; - return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: BAND_TAG[band].label }; + return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: PD_BAND_TAG[band].label }; }); protected readonly ecosystemBandMeta = computed(() => { const band = this.ecosystemBand(); if (!band) return null; - return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: BAND_TAG[band].label }; + return { chipClass: BAND_CHIP_CLASS[band], bars: this.buildBandBars(band), label: PD_BAND_TAG[band].label }; }); // Our Influence tab — Technical + Ecosystem cards (per-card chart type and data). private readonly monthLabels: string[] = this.buildMonthLabels(); protected readonly technicalCards = computed(() => { - const months = TIME_RANGE_MONTHS[this.timeRange()]; + const months = PD_TIME_RANGE_MONTHS[this.timeRange()]; return (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical', months)); }); protected readonly ecosystemCards = computed(() => { - const months = TIME_RANGE_MONTHS[this.timeRange()]; + const months = PD_TIME_RANGE_MONTHS[this.timeRange()]; return (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem', months)); }); // Leaderboards tab — URL-persisted metric toggle + time range + two side-by-side boards + stacked trend. - protected readonly metricOptions = METRIC_OPTIONS; - protected readonly timeRangeOptions = TIME_RANGE_OPTIONS; + protected readonly metricOptions = PD_METRIC_OPTIONS; + protected readonly timeRangeOptions = PD_TIME_RANGE_OPTIONS; protected readonly metric = computed(() => this.initMetric()); protected readonly timeRange = computed(() => this.initTimeRange()); protected readonly isActivityMode = computed(() => this.metric() === 'activity'); protected readonly scoreColumnLabel = computed(() => { if (!this.isActivityMode()) return 'Influence Score'; - return `Activity (${TIME_RANGE_MONTHS[this.timeRange()]}mo)`; + return `Activity (${PD_TIME_RANGE_MONTHS[this.timeRange()]}mo)`; }); - protected readonly drawerTimeRangeLabel = computed(() => `Last ${TIME_RANGE_MONTHS[this.timeRange()]} months`); + protected readonly drawerTimeRangeLabel = computed(() => `Last ${PD_TIME_RANGE_MONTHS[this.timeRange()]} months`); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly techSearchHasQuery = computed(() => this.techSearch().trim().length > 0); @@ -228,7 +230,7 @@ export class OrgProjectDetailComponent { if (this.metric() === metric) return; void this.router.navigate([], { relativeTo: this.route, - queryParams: { metric: metric === DEFAULT_METRIC ? null : metric }, + queryParams: { metric: metric === PD_DEFAULT_METRIC ? null : metric }, queryParamsHandling: 'merge', replaceUrl: true, }); @@ -238,7 +240,7 @@ export class OrgProjectDetailComponent { if (this.timeRange() === range) return; void this.router.navigate([], { relativeTo: this.route, - queryParams: { range: range === DEFAULT_TIME_RANGE ? null : range }, + queryParams: { range: range === PD_DEFAULT_TIME_RANGE ? null : range }, queryParamsHandling: 'merge', replaceUrl: true, }); @@ -270,12 +272,12 @@ export class OrgProjectDetailComponent { private initMetric(): OrgLensLeaderboardMetric { const raw = this.queryParamMap().get('metric'); - return raw && VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : DEFAULT_METRIC; + return raw && PD_VALID_METRICS.has(raw) ? (raw as OrgLensLeaderboardMetric) : PD_DEFAULT_METRIC; } private initTimeRange(): OrgLensLeaderboardTimeRange { const raw = this.queryParamMap().get('range'); - return raw && VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : DEFAULT_TIME_RANGE; + return raw && PD_VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : PD_DEFAULT_TIME_RANGE; } /** @@ -291,7 +293,7 @@ export class OrgProjectDetailComponent { })); valued.sort((a, b) => b.sortKey - a.sortKey || a.row.orgName.localeCompare(b.row.orgName)); const ranked = valued.map((entry, i) => { - const bandMeta = isActivity ? null : BAND_TAG[bandForScore(entry.score)]; + const bandMeta = isActivity ? null : PD_BAND_TAG[bandForScore(entry.score)]; return { rank: i + 1, orgName: entry.row.orgName, @@ -558,7 +560,7 @@ export class OrgProjectDetailComponent { const board = this.detail()?.leaderboard ?? []; if (board.length === 0) return { labels: [], datasets: [] }; - const months = TIME_RANGE_MONTHS[this.timeRange()]; + const months = PD_TIME_RANGE_MONTHS[this.timeRange()]; const labels = this.monthLabels.slice(-months); const sorted = [...board].sort((a, b) => b.scores.combined - a.scores.combined); @@ -589,7 +591,7 @@ export class OrgProjectDetailComponent { // datasets[N] = least influential (top of stack, last/right in legend). // This matches the standard 100% stacked area convention used in the reference design. const datasets = ranked.map((item, rankIdx) => { - const color = STACKED_PALETTE[rankIdx] ?? lfxColors.gray[300]; + const color = PD_STACKED_PALETTE[rankIdx] ?? lfxColors.gray[300]; return { label: item.entry.name, data: item.pct, diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 76d241f05..3938bb47f 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -11,7 +11,6 @@ import type { OrgLensProjectHealth, OrgLensProjectInfluenceCard, OrgLensProjectLeaderboardRow, - OrgLensProjectTrendPoint, } from '@lfx-one/shared/interfaces'; /** @@ -28,7 +27,6 @@ interface ProjectDetailSeed { description: string; foundationLabel: string; health: OrgLensProjectHealth; - sourceUrl: string; firstCommit: string; softwareValueUsd: number; /** The org's own counts (rolling 365d). */ @@ -46,7 +44,6 @@ const SEEDS: Record = { description: 'Kubernetes (K8s) is an open-source system for automating deployment, scaling, and management of containerized applications.', foundationLabel: 'Cloud Native Computing Foundation', health: 'excellent', - sourceUrl: 'https://github.com/kubernetes/kubernetes', firstCommit: '2013-07-01', softwareValueUsd: 6000000000, org: { maintainers: 5, contributors: 6, commits: 1840, prs: 624 }, @@ -59,7 +56,6 @@ const SEEDS: Record = { description: 'The monitoring system and time-series database.', foundationLabel: 'Cloud Native Computing Foundation', health: 'excellent', - sourceUrl: 'https://github.com/prometheus/prometheus', firstCommit: '2012-11-24', softwareValueUsd: 214000000, org: { maintainers: 2, contributors: 3, commits: 612, prs: 208 }, @@ -72,7 +68,6 @@ const SEEDS: Record = { description: 'Cloud-native high-performance edge/middle/service proxy.', foundationLabel: 'Cloud Native Computing Foundation', health: 'healthy', - sourceUrl: 'https://github.com/envoyproxy/envoy', firstCommit: '2016-08-30', softwareValueUsd: 178000000, org: { maintainers: 3, contributors: 2, commits: 503, prs: 171 }, @@ -85,7 +80,6 @@ const SEEDS: Record = { description: 'High-quality, ubiquitous, portable telemetry.', foundationLabel: 'Cloud Native Computing Foundation', health: 'excellent', - sourceUrl: 'https://github.com/open-telemetry', firstCommit: '2019-05-07', softwareValueUsd: 142000000, org: { maintainers: 2, contributors: 4, commits: 588, prs: 196 }, @@ -98,7 +92,6 @@ const SEEDS: Record = { description: 'Kubernetes-native workflows, events, CD and rollouts.', foundationLabel: 'Cloud Native Computing Foundation', health: 'healthy', - sourceUrl: 'https://github.com/argoproj', firstCommit: '2017-08-21', softwareValueUsd: 96400000, org: { maintainers: 1, contributors: 2, commits: 412, prs: 138 }, @@ -111,7 +104,6 @@ const SEEDS: Record = { description: 'Tensors and dynamic neural networks with strong GPU acceleration.', foundationLabel: 'LF AI & Data', health: 'excellent', - sourceUrl: 'https://github.com/pytorch/pytorch', firstCommit: '2016-08-13', softwareValueUsd: 642000000, org: { maintainers: 3, contributors: 4, commits: 980, prs: 332 }, @@ -124,7 +116,6 @@ const SEEDS: Record = { description: 'Open standard for machine-learning interoperability.', foundationLabel: 'LF AI & Data', health: 'healthy', - sourceUrl: 'https://github.com/onnx/onnx', firstCommit: '2017-09-07', softwareValueUsd: 58200000, org: { maintainers: 1, contributors: 2, commits: 214, prs: 72 }, @@ -137,7 +128,6 @@ const SEEDS: Record = { description: 'Open Network Automation Platform.', foundationLabel: 'LF Networking', health: 'at-risk', - sourceUrl: 'https://github.com/onap', firstCommit: '2017-02-15', softwareValueUsd: 39400000, org: { maintainers: 1, contributors: 1, commits: 118, prs: 40 }, @@ -150,7 +140,6 @@ const SEEDS: Record = { description: 'Fast data-plane I/O for the network stack.', foundationLabel: 'LF Networking', health: 'at-risk', - sourceUrl: 'https://github.com/FDio', firstCommit: '2016-02-11', softwareValueUsd: 21800000, org: { maintainers: 0, contributors: 1, commits: 62, prs: 21 }, @@ -163,7 +152,6 @@ const SEEDS: Record = { description: 'A new standard for signing, verifying and protecting software.', foundationLabel: 'OpenSSF', health: 'healthy', - sourceUrl: 'https://github.com/sigstore', firstCommit: '2020-12-08', softwareValueUsd: 47600000, org: { maintainers: 2, contributors: 2, commits: 318, prs: 106 }, @@ -176,7 +164,6 @@ const SEEDS: Record = { description: 'A framework to secure the integrity of software supply chains.', foundationLabel: 'OpenSSF', health: 'healthy', - sourceUrl: 'https://github.com/in-toto', firstCommit: '2017-06-12', softwareValueUsd: 18900000, org: { maintainers: 1, contributors: 1, commits: 142, prs: 48 }, @@ -189,7 +176,6 @@ const SEEDS: Record = { description: 'Cloud-native CI/CD building blocks.', foundationLabel: 'CD Foundation', health: 'healthy', - sourceUrl: 'https://github.com/tektoncd', firstCommit: '2018-08-10', softwareValueUsd: 34200000, org: { maintainers: 1, contributors: 2, commits: 256, prs: 86 }, @@ -202,7 +188,6 @@ const SEEDS: Record = { description: 'The leading open-source automation server.', foundationLabel: 'CD Foundation', health: 'at-risk', - sourceUrl: 'https://github.com/jenkinsci', firstCommit: '2011-02-02', softwareValueUsd: 12400000, // Archived in our workspace — the org has no current maintainers/contributors here (empty-card demo). @@ -216,7 +201,6 @@ const SEEDS: Record = { description: 'A universal identity control plane for distributed systems.', foundationLabel: 'Cloud Native Computing Foundation', health: 'healthy', - sourceUrl: 'https://github.com/spiffe', firstCommit: '2018-01-26', softwareValueUsd: 41100000, org: { maintainers: 2, contributors: 3, commits: 372, prs: 124 }, @@ -226,17 +210,6 @@ const SEEDS: Record = { }, }; -/** Twelve year-month bins ending at the current month (oldest → newest), e.g. "2025-07". */ -function trailing12Months(): string[] { - const out: string[] = []; - const now = new Date(); - for (let i = 11; i >= 0; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); - } - return out; -} - /** Deterministic 12-point ramp ending at `end` (starts ~`startFactor` of it). No RNG → SSR-stable. */ function ramp(end: number, startFactor: number, round = 0): number[] { if (end === 0) return []; @@ -414,14 +387,6 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation ]; } -function trendSeries(seed: ProjectDetailSeed): OrgLensProjectTrendPoint[] { - const months = trailing12Months(); - const combined = ramp(seed.influence.combined, 0.83, 1); - const technical = ramp(seed.influence.technical, 0.85, 1); - const ecosystem = ramp(seed.influence.ecosystem, 0.8, 1); - return months.map((month, i) => ({ month, combined: combined[i] ?? 0, technical: technical[i] ?? 0, ecosystem: ecosystem[i] ?? 0 })); -} - /** * Competitor pool with relative strength (0..1) plus per-org technical / ecosystem biases so the * score-type toggle genuinely re-ranks (an org strong technically but weak on ecosystem moves). @@ -806,19 +771,14 @@ export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlu projectName: seed.name, description: seed.description, logoUrl: PROJECT_LOGO_URLS[projectSlug] ?? '', - sourceUrl: seed.sourceUrl, - sourceLabel: `${seed.name} - ${seed.description.replace(/\.$/, '')}`, lfxInsightsUrl: `https://insights.linuxfoundation.org/project/${projectSlug}`, firstCommit: seed.firstCommit, softwareValueUsd: seed.softwareValueUsd, health: seed.health, foundationLabel: seed.foundationLabel, - // Computed per-request as ~1h ago so the freshness label renders a stable relative value in demo. - lastUpdated: new Date(Date.now() - 60 * 60 * 1000).toISOString(), }, technical: technicalCards(seed), ecosystem: ecosystemCards(seed, seed.name, seed.foundationLabel), - trend: trendSeries(seed), leaderboard: leaderboard(seed, orgName), cardDetails: buildCardDetails(seed), }; diff --git a/packages/shared/src/constants/org-lens-project-detail.constants.ts b/packages/shared/src/constants/org-lens-project-detail.constants.ts index f1a891e03..baa821719 100644 --- a/packages/shared/src/constants/org-lens-project-detail.constants.ts +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -14,21 +14,21 @@ import { lfxColors } from './colors.constants'; export const PD_DEFAULT_TAB: OrgLensProjectDetailTab = 'pd-influence'; export const PD_VALID_TABS: ReadonlySet = new Set(['pd-influence', 'pd-leaderboards']); -export const DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; -export const VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); +export const PD_DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; +export const PD_VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); -export const DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '1y'; -export const VALID_TIME_RANGES: ReadonlySet = new Set(['1y', '2y', 'all']); +export const PD_DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '1y'; +export const PD_VALID_TIME_RANGES: ReadonlySet = new Set(['1y', '2y', 'all']); /** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ -export const HEALTH_TAG: Record = { +export const PD_HEALTH_TAG: Record = { excellent: { label: 'Excellent', severity: 'success' }, healthy: { label: 'Healthy', severity: 'warn' }, 'at-risk': { label: 'At Risk', severity: 'danger' }, }; /** Leaderboard band chip → lfx-tag severity. */ -export const BAND_TAG: Record = { +export const PD_BAND_TAG: Record = { leading: { label: 'Leading', severity: 'success' }, contributing: { label: 'Contributing', severity: 'info' }, participating: { label: 'Participating', severity: 'warn' }, @@ -63,21 +63,21 @@ export const BAND_CHIP_CLASS: Record = { 'non-lf': 'inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600', }; -export const METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string; icon: string }[] = [ +export const PD_METRIC_OPTIONS: { id: OrgLensLeaderboardMetric; label: string; icon: string }[] = [ { id: 'influence', label: 'Calculated Influence', icon: 'fa-light fa-chart-bar' }, { id: 'activity', label: 'Activity Count', icon: 'fa-light fa-list-ol' }, ]; -export const TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ +export const PD_TIME_RANGE_OPTIONS: { id: OrgLensLeaderboardTimeRange; label: string }[] = [ { id: '1y', label: '1 year' }, { id: '2y', label: '2 years' }, { id: 'all', label: 'All time' }, ]; -export const TIME_RANGE_MONTHS: Record = { '1y': 12, '2y': 24, all: 36 }; +export const PD_TIME_RANGE_MONTHS: Record = { '1y': 12, '2y': 24, all: 36 }; /** 11-slot palette for the stacked trend chart — top-10 companies + "All others". */ -export const STACKED_PALETTE: string[] = [ +export const PD_STACKED_PALETTE: string[] = [ lfxColors.blue[600], lfxColors.blue[400], lfxColors.emerald[500], diff --git a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts index c09f4fee4..8bf2a0a27 100644 --- a/packages/shared/src/interfaces/org-lens-project-detail.interface.ts +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -54,10 +54,6 @@ export interface OrgLensProjectHero { description: string; /** Project logo URL; empty string falls back to initials. */ logoUrl: string; - /** External source link target (e.g. the project's GitHub / homepage). */ - sourceUrl: string | null; - /** Human label for the source link (e.g. "Kubernetes - Production-grade container orchestration"). */ - sourceLabel: string | null; lfxInsightsUrl: string | null; /** Project's earliest commit ever (any author), ISO date. */ firstCommit: string | null; @@ -65,8 +61,6 @@ export interface OrgLensProjectHero { softwareValueUsd: number | null; health: OrgLensProjectHealth; foundationLabel: string; - /** Last-updated timestamp, ISO. */ - lastUpdated: string; } /** @@ -116,15 +110,6 @@ export interface OrgLensCardDetailSection { rows: OrgLensCardDetailRow[]; } -/** One monthly point on the Influence Trend chart. */ -export interface OrgLensProjectTrendPoint { - /** Year-month bin, e.g. "2025-07". */ - month: string; - combined: number; - technical: number; - ecosystem: number; -} - /** * One organization row on the project leaderboard. Rank and band are derived client-side * from the active score-type / metric, so they are not carried on the wire. @@ -152,7 +137,6 @@ export interface OrgLensProjectDetailResponse { hero: OrgLensProjectHero; technical: OrgLensProjectInfluenceCard[]; ecosystem: OrgLensProjectInfluenceCard[]; - trend: OrgLensProjectTrendPoint[]; /** All organizations contributing to the project; the viewing-org row is always included. */ leaderboard: OrgLensProjectLeaderboardRow[]; /** Keyed by card key — drawer definition + card-specific data table for each influence card. */ From 4b0dc690ababfc2663857a7b8aac3947f6c4d48f Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 2 Jul 2026 17:49:00 -0400 Subject: [PATCH 73/80] fix(review): fall back to demo org uid on project detail page Address review comment from copilot-pull-request-reviewer[bot]: - org-project-detail.component.ts: orgUid$ no longer filters out until an org is selected; falls back to 'demo-org' like the sibling org-projects.component.ts does, so the page renders in demo mode instead of hanging on the loading skeleton indefinitely. Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index ede25c45e..bd519c8cb 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -315,7 +315,7 @@ export class OrgProjectDetailComponent { } private initDetailStream(): Observable { - const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid)).pipe(filter((uid): uid is string => !!uid)); + const orgUid$ = toObservable(computed(() => this.accountContext.selectedAccount()?.uid ?? 'demo-org')); const orgName$ = toObservable(computed(() => this.accountContext.selectedAccount()?.accountName ?? '')); const projectSlug$ = this.route.paramMap.pipe(map((params) => params.get('projectSlug'))); const retryTrigger$ = toObservable(this.retryTrigger); From 83c6b799d9ec607f1b8e8fb4e3b8cc94b1cc44f9 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 2 Jul 2026 17:57:18 -0400 Subject: [PATCH 74/80] fix(review): revert demo-org fallback and add row aria-label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - org-project-detail.component.ts: reverted the demo-org uid fallback added in 4b0dc690 — this page hits a real HTTP endpoint validated by assertOrgUid (18-char Salesforce Account.Id), so the fallback would reliably 400 instead of rendering. orgUid$ now waits for a real org uid again, matching the other HTTP-backed Org Lens pages. - org-overview-foundations-and-projects.component.html: added an aria-label to the clickable project row so screen readers announce what activating it does, matching the pattern in contributors.component.html. Signed-off-by: daniel qualls --- .../org-overview-foundations-and-projects.component.html | 1 + .../org/org-project-detail/org-project-detail.component.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html index 72b978dc3..feb70e3a6 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html @@ -96,6 +96,7 @@

+ @if (!hasCompany()) { + + } @else { @switch (pageState()) { @case ('loading') {
@@ -239,16 +242,16 @@

Our Ecosystem Influence

Technical Influence Leaderboard
- - Technical leaderboard search + + size="small" + styleClass="w-40" + dataTest="project-detail-search-technical" />
@if (technicalBoard().length === 0) { @@ -316,16 +319,16 @@

Our Ecosystem Influence

Ecosystem Influence Leaderboard
- - Ecosystem leaderboard search + + size="small" + styleClass="w-40" + dataTest="project-detail-search-ecosystem" />

@if (ecosystemBoard().length === 0) { @@ -409,6 +412,7 @@

Influence Trend

} } } + }
@@ -465,7 +469,7 @@

-

{{ card.caption.emphasis }}

+

{{ card.caption.emphasis || '—' }}

{{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }}

@@ -507,7 +511,7 @@

- @for (dataRow of detail.rows; track $index) { + @for (dataRow of detail.rows; track rowKey(dataRow)) { @for (cell of dataRow.cells; track $index) { diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index ede25c45e..56b991989 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -4,12 +4,14 @@ import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common'; import { Component, computed, ElementRef, inject, PLATFORM_ID, signal, type Signal, viewChild } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { AccountContextService } from '@services/account-context.service'; import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.service'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ChartComponent } from '@components/chart/chart.component'; import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; import { TableComponent } from '@components/table/table.component'; import { TagComponent } from '@components/tag/tag.component'; import { OrgProjectDetailTabBarComponent } from './org-project-detail-tab-bar.component'; @@ -35,6 +37,7 @@ import { import type { InfluenceCardVm, LeaderboardDimension, + OrgLensCardDetailRow, OrgLensCardDetailSection, OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, @@ -51,7 +54,7 @@ import { InputTextModule } from 'primeng/inputtext'; import { SkeletonModule } from 'primeng/skeleton'; import { TooltipModule } from 'primeng/tooltip'; import type { ChartData, ChartOptions, ChartType } from 'chart.js'; -import { catchError, combineLatest, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; +import { catchError, combineLatest, debounceTime, filter, map, type Observable, of, switchMap, tap } from 'rxjs'; /** Band thresholds per Boysel et al. markup-mu (Leading / Contributing / Participating / Non-LF). */ function bandForScore(score: number): OrgLensProjectBand { @@ -72,9 +75,11 @@ function bandForScore(score: number): OrgLensProjectBand { selector: 'lfx-org-project-detail', imports: [ NgTemplateOutlet, + ReactiveFormsModule, BreadcrumbComponent, ChartComponent, EmptyStateComponent, + InputTextComponent, OrgProjectDetailTabBarComponent, TableComponent, TagComponent, @@ -119,9 +124,7 @@ export class OrgProjectDetailComponent { protected readonly activeTab: Signal = computed(() => this.initActiveTab()); protected readonly pageState: Signal = computed(() => this.initPageState()); - - /** The viewing org's display name — falls back to a demo placeholder when no org is selected. */ - protected readonly orgName = computed(() => this.accountContext.selectedAccount()?.accountName ?? 'Acme Corp'); + protected readonly hasCompany = computed(() => !!this.accountContext.selectedAccount().uid); // Hero presentation — derived from the loaded payload. protected readonly hero = computed(() => this.detail()?.hero ?? null); @@ -177,6 +180,10 @@ export class OrgProjectDetailComponent { return `Activity (${PD_TIME_RANGE_MONTHS[this.timeRange()]}mo)`; }); protected readonly drawerTimeRangeLabel = computed(() => `Last ${PD_TIME_RANGE_MONTHS[this.timeRange()]} months`); + protected readonly searchForm = new FormGroup({ + technical: new FormControl('', { nonNullable: true }), + ecosystem: new FormControl('', { nonNullable: true }), + }); protected readonly techSearch = signal(''); protected readonly ecoSearch = signal(''); protected readonly techSearchHasQuery = computed(() => this.techSearch().trim().length > 0); @@ -193,6 +200,13 @@ export class OrgProjectDetailComponent { protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); constructor() { + this.searchForm.controls.technical.valueChanges + .pipe(debounceTime(250), takeUntilDestroyed()) + .subscribe((value) => this.techSearch.set(value)); + this.searchForm.controls.ecosystem.valueChanges + .pipe(debounceTime(250), takeUntilDestroyed()) + .subscribe((value) => this.ecoSearch.set(value)); + // React when tab or card counts change to refresh horizontal scroll arrows. toObservable( computed(() => ({ @@ -246,11 +260,6 @@ export class OrgProjectDetailComponent { }); } - protected onSearch(dimension: LeaderboardDimension, event: Event): void { - const value = (event.target as HTMLInputElement).value; - (dimension === 'technical' ? this.techSearch : this.ecoSearch).set(value); - } - protected openCardDetail(card: InfluenceCardVm): void { this.selectedCard.set(card); this.drawerOpen.set(true); @@ -266,6 +275,10 @@ export class OrgProjectDetailComponent { } /** Updates left/right arrow visibility from the track's scroll position. */ + protected rowKey(row: OrgLensCardDetailRow): string { + return row.cells.map((cell) => cell.person?.name ?? cell.text ?? '').join('|'); + } + protected onTrackScroll(el: HTMLElement, track: 'tech' | 'eco'): void { this.refreshArrows(el, track === 'tech'); } @@ -337,6 +350,7 @@ export class OrgProjectDetailComponent { }), tap((response) => { this.detail.set(response); + this.searchForm.reset({ technical: '', ecosystem: '' }); this.techSearch.set(''); this.ecoSearch.set(''); if (!this.fetchError()) this.fetchLoading.set(false); @@ -424,25 +438,34 @@ export class OrgProjectDetailComponent { tip.style.top = `${rect.top + tooltip.caretY}px`; tip.style.transform = 'translateY(-100%)'; - const esc = (value: string) => value.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] ?? ch); - - const title = esc(tooltip.title?.[0] ?? ''); - const rows = (tooltip.dataPoints ?? []) - .map((p) => { - const label = esc(p.dataset.label ?? ''); - const formatted = esc(`${p.formattedValue}${valueSuffix}`); - const dotColor = esc(p.dataset.borderColor ?? ''); - return ( - '
' + - `` + - `${label}: ` + - `${formatted}` + - '
' - ); - }) - .join(''); - - tip.innerHTML = `

${title}

${rows}`; + tip.replaceChildren(); + + const titleEl = document.createElement('p'); + titleEl.style.cssText = 'font-size:12px;font-weight:600;color:#111827;white-space:nowrap'; + titleEl.textContent = tooltip.title?.[0] ?? ''; + tip.appendChild(titleEl); + + for (const p of tooltip.dataPoints ?? []) { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-top:6px'; + + const dot = document.createElement('span'); + dot.style.cssText = `width:8px;height:8px;border-radius:9999px;flex-shrink:0;background:${p.dataset.borderColor ?? ''}`; + row.appendChild(dot); + + const labelEl = document.createElement('span'); + labelEl.style.cssText = 'font-size:12px;color:#6B7280;white-space:nowrap'; + labelEl.textContent = `${p.dataset.label ?? ''}: `; + + const valueEl = document.createElement('strong'); + valueEl.style.cssText = 'color:#111827;font-weight:600'; + valueEl.textContent = `${p.formattedValue}${valueSuffix}`; + labelEl.appendChild(valueEl); + row.appendChild(labelEl); + + tip.appendChild(row); + } + tip.style.display = 'block'; }; } From 232083b376263eb7214252e7cef3d7ff9b7b8ea2 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Fri, 3 Jul 2026 12:47:29 -0400 Subject: [PATCH 76/80] style(org-lens): fix prettier formatting in project detail component Fixes the failing "Code Quality Checks" CI job on PR #1028, which flagged formatting drift in these two files after the recent review fixes and merge from main. Signed-off-by: daniel qualls --- .../org-project-detail.component.html | 761 +++++++++--------- .../org-project-detail.component.ts | 8 +- 2 files changed, 384 insertions(+), 385 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html index abd06d8d3..f55f72bcf 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -3,416 +3,419 @@
@if (!hasCompany()) { - + } @else { - @switch (pageState()) { - @case ('loading') { -
- - - - -
- } + @switch (pageState()) { + @case ('loading') { +
+ + + + +
+ } - @case ('error') { - - - } + @case ('error') { + + + } - @case ('notFound') { - - - } + @case ('notFound') { + + + } - @default { - @if (hero(); as h) { - - - + @default { + @if (hero(); as h) { + + + - -
- -
-
- @if (h.logoUrl) { - - } @else { - - } -

{{ h.projectName }}

+ +
+ +
+
+ @if (h.logoUrl) { + + } @else { + + } +

{{ h.projectName }}

+
+

+ {{ h.description }} + @if (h.lfxInsightsUrl) { + Source: + + LFX Insights + + + } +

-

- {{ h.description }} - @if (h.lfxInsightsUrl) { - Source: - - LFX Insights - - - } -

-
- -
-
- First commit - {{ firstCommitLabel() }} -
-
- - Software value - - - {{ softwareValueLabel() }} -
-
- Health score - @if (healthMeta(); as hm) { - - + +
+
+ First commit + {{ firstCommitLabel() }} +
+
+ + Software value + - } -
-
- Foundation - {{ h.foundationLabel }} + {{ softwareValueLabel() }} +
+
+ Health score + @if (healthMeta(); as hm) { + + + + } +
+
+ Foundation + {{ h.foundationLabel }} +
-
-
+ - - - + + + - - @switch (activeTab()) { - @case ('pd-influence') { -
- -
-
-

Our Technical Influence

- @if (technicalBandMeta(); as meta) { - - - {{ meta.label }} - - } -
-
- - -
-
-
-
- @for (card of technicalCards(); track card.key) { - + + @switch (activeTab()) { + @case ('pd-influence') { +
+ +
+
+

Our Technical Influence

+ @if (technicalBandMeta(); as meta) { + + + {{ meta.label }} + } +
+
+ + +
-
-
- - -
-
-

Our Ecosystem Influence

- @if (ecosystemBandMeta(); as meta) { - - - {{ meta.label }} - - } -
-
- - +
+
+ @for (card of technicalCards(); track card.key) { + + } +
-
-
-
- @for (card of ecosystemCards(); track card.key) { - +
+ + +
+
+

Our Ecosystem Influence

+ @if (ecosystemBandMeta(); as meta) { + + + {{ meta.label }} + } -
-
- -
- } - @case ('pd-leaderboards') { -
- -
- -
-
-
-
Technical Influence Leaderboard
+
+
+ +
-
- - +
+
+
+ @for (card of ecosystemCards(); track card.key) { + + }
- @if (technicalBoard().length === 0) { -

- {{ techSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} -

- } @else { - - - - # - Organization - {{ scoreColumnLabel() }} - - - - - {{ row.rank }} - -
- @if (row.orgLogoUrl) { - + +
+ } + @case ('pd-leaderboards') { +
+ +
+ +
+
+
+
Technical Influence Leaderboard
+
+
+ + +
+
+ @if (technicalBoard().length === 0) { +

+ {{ techSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} +

+ } @else { + + + + # + Organization + {{ scoreColumnLabel() }} + + + + + {{ row.rank }} + +
+ @if (row.orgLogoUrl) { + + } @else { + + } + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
+ + + @if (isActivityMode()) { + {{ row.activityLabel }} } @else { - + } - {{ row.orgName }} - @if (row.isViewingOrg) { - You - } -
- - - @if (isActivityMode()) { - {{ row.activityLabel }} - } @else { - - } - - - - - } -
+ + + + + } +
- -
-
-
-
Ecosystem Influence Leaderboard
-
-
- - + +
+
+
+
Ecosystem Influence Leaderboard
+
+
+ + +
-
- @if (ecosystemBoard().length === 0) { -

- {{ ecoSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} -

- } @else { - - - - # - Organization - {{ scoreColumnLabel() }} - - - - - {{ row.rank }} - -
- @if (row.orgLogoUrl) { - + @if (ecosystemBoard().length === 0) { +

+ {{ ecoSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }} +

+ } @else { + + + + # + Organization + {{ scoreColumnLabel() }} + + + + + {{ row.rank }} + +
+ @if (row.orgLogoUrl) { + + } @else { + + } + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
+ + + @if (isActivityMode()) { + {{ row.activityLabel }} } @else { - - } - {{ row.orgName }} - @if (row.isViewingOrg) { - You + } -
- - - @if (isActivityMode()) { - {{ row.activityLabel }} - } @else { - - } - - -
-
- } + + + + + } +
-
- -
-
-

Influence Trend

-
-
-
- @if (hasStackedTrend()) { -
- -
- } @else { -

No data available.

- } -
-
-
+ +
+
+

Influence Trend

+
+
+
+ @if (hasStackedTrend()) { +
+ +
+ } @else { +

No data available.

+ } +
+
+
+ } } } } } } - }
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 56b991989..28ffa9296 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -200,12 +200,8 @@ export class OrgProjectDetailComponent { protected readonly detailData = toSignal(this.initDetailStream(), { initialValue: null }); constructor() { - this.searchForm.controls.technical.valueChanges - .pipe(debounceTime(250), takeUntilDestroyed()) - .subscribe((value) => this.techSearch.set(value)); - this.searchForm.controls.ecosystem.valueChanges - .pipe(debounceTime(250), takeUntilDestroyed()) - .subscribe((value) => this.ecoSearch.set(value)); + this.searchForm.controls.technical.valueChanges.pipe(debounceTime(250), takeUntilDestroyed()).subscribe((value) => this.techSearch.set(value)); + this.searchForm.controls.ecosystem.valueChanges.pipe(debounceTime(250), takeUntilDestroyed()).subscribe((value) => this.ecoSearch.set(value)); // React when tab or card counts change to refresh horizontal scroll arrows. toObservable( From 3597e1a172b50f35685df5ef5d5283c4df3fae8c Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Fri, 3 Jul 2026 12:52:56 -0400 Subject: [PATCH 77/80] fix(review): address PR #1028 Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - org-project-detail.component.ts: corrected a stale JSDoc comment above rowKey() that described scroll-arrow visibility behavior (which actually lives in onTrackScroll()/refreshArrows()) — now documents what rowKey() does. - PR description: fixed the stale Ecosystem Influence card list to match the shipped cards (Event Sponsorships, Meetup Attendance instead of the never-implemented Training Completions and Mailing List Posts). Resolves 2 review threads. Signed-off-by: daniel qualls --- .../org/org-project-detail/org-project-detail.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 28ffa9296..5561d34bd 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -270,7 +270,7 @@ export class OrgProjectDetailComponent { el.scrollBy({ left: direction * 336, behavior: 'smooth' }); } - /** Updates left/right arrow visibility from the track's scroll position. */ + /** Builds a stable `@for` track key from a detail row's cells. */ protected rowKey(row: OrgLensCardDetailRow): string { return row.cells.map((cell) => cell.person?.name ?? cell.text ?? '').join('|'); } From 010c669ef2a3d155e53c865e18f9bbe5726ca716 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Fri, 3 Jul 2026 12:59:51 -0400 Subject: [PATCH 78/80] fix(review): address PR #1028 Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - org-lens-project-detail.demo-data.ts: capitalized the leaderboard viewing-org fallback copy ("Your Organization") to match the title case already used on org-overview.component.ts and the org-lens-project-detail.controller.ts fallback. - Kept ProjectDetailSeed/Caption as `interface` rather than switching to `type` — the repo's `@typescript-eslint/consistent-type-definitions` ESLint rule requires `interface` for object shapes, so the suggested change would fail lint. No code change needed for that finding. Resolves 1 review thread; 2 threads addressed with an explanation. Signed-off-by: daniel qualls --- .../src/server/services/org-lens-project-detail.demo-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 3938bb47f..41cdaa047 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -438,7 +438,7 @@ function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLe const viewingSpark = ramp(seed.influence.combined, 0.82, 1); rows.push({ - orgName: orgName || 'Your organization', + orgName: orgName || 'Your Organization', orgLogoUrl: '', scores: { combined: round1(seed.influence.combined), technical: round1(seed.influence.technical), ecosystem: round1(seed.influence.ecosystem) }, activityCount: Math.round(seed.influence.combined * 46), From 7ad8323d8440c456140060c92b704b15be357368 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Fri, 3 Jul 2026 13:11:49 -0400 Subject: [PATCH 79/80] fix(review): address PR #1028 Copilot review feedback Address review comments from copilot[bot]: - org-project-detail.component.ts: technicalCards/ecosystemCards now always slice influence-card sparklines at the default 12-month window instead of the URL-persisted timeRange (12/24/36), matching the OrgLensProjectInfluenceCard.sparkline contract (fixed 12 monthly bins). timeRange now only affects the Leaderboards tab. - org-project-detail.component.ts: scoreColumnLabel and drawerTimeRangeLabel special-case range=all to render "Activity (All time)" / "All time" instead of "Activity (36mo)" / "Last 36 months". - org-lens-project-detail.demo-data.ts: extracted a `plural(n, singular, plural)` helper and used it to remove three nested ternaries in the maintainers, meeting-attendance, and board-members caption builders. Resolves 5 review threads. Signed-off-by: daniel qualls --- .../org-project-detail.component.ts | 9 ++++----- .../services/org-lens-project-detail.demo-data.ts | 14 +++++++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts index 5561d34bd..46c1e9392 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -161,12 +161,10 @@ export class OrgProjectDetailComponent { // Our Influence tab — Technical + Ecosystem cards (per-card chart type and data). private readonly monthLabels: string[] = this.buildMonthLabels(); protected readonly technicalCards = computed(() => { - const months = PD_TIME_RANGE_MONTHS[this.timeRange()]; - return (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical', months)); + return (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')); }); protected readonly ecosystemCards = computed(() => { - const months = PD_TIME_RANGE_MONTHS[this.timeRange()]; - return (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem', months)); + return (this.detail()?.ecosystem ?? []).map((card) => this.toInfluenceCard(card, lfxColors.violet[500], 'ecosystem')); }); // Leaderboards tab — URL-persisted metric toggle + time range + two side-by-side boards + stacked trend. @@ -177,9 +175,10 @@ export class OrgProjectDetailComponent { protected readonly isActivityMode = computed(() => this.metric() === 'activity'); protected readonly scoreColumnLabel = computed(() => { if (!this.isActivityMode()) return 'Influence Score'; + if (this.timeRange() === 'all') return 'Activity (All time)'; return `Activity (${PD_TIME_RANGE_MONTHS[this.timeRange()]}mo)`; }); - protected readonly drawerTimeRangeLabel = computed(() => `Last ${PD_TIME_RANGE_MONTHS[this.timeRange()]} months`); + protected readonly drawerTimeRangeLabel = computed(() => (this.timeRange() === 'all' ? 'All time' : `Last ${PD_TIME_RANGE_MONTHS[this.timeRange()]} months`)); protected readonly searchForm = new FormGroup({ technical: new FormControl('', { nonNullable: true }), ecosystem: new FormControl('', { nonNullable: true }), diff --git a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts index 41cdaa047..a54f20462 100644 --- a/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -227,6 +227,10 @@ function shareOf(n: number, total: number): number { return total === 0 ? 0 : n / total; } +function plural(n: number, singular: string, pluralForm: string): string { + return n === 1 ? singular : pluralForm; +} + interface Caption { prefix: string; emphasis: string; @@ -269,7 +273,7 @@ function technicalCards(seed: ProjectDetailSeed): OrgLensProjectInfluenceCard[] : { prefix: 'Our company employs ', emphasis: `${org.maintainers}`, - suffix: ` ${org.maintainers === 1 ? 'maintainer' : 'maintainers'} for this project.`, + suffix: ` ${plural(org.maintainers, 'maintainer', 'maintainers')} for this project.`, } ), card('contributors', 'Contributors', null, ramp(org.contributors, 0.55), projContributors, { @@ -333,7 +337,11 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation projMeetingAvg, eco.meetingAttendance === 0 ? { prefix: 'Our company has no meeting attendance for this project.', emphasis: '', suffix: '' } - : { prefix: 'Org reps attended ', emphasis: `${eco.meetingAttendance}`, suffix: ` project ${eco.meetingAttendance === 1 ? 'meeting' : 'meetings'}.` } + : { + prefix: 'Org reps attended ', + emphasis: `${eco.meetingAttendance}`, + suffix: ` project ${plural(eco.meetingAttendance, 'meeting', 'meetings')}.`, + } ), card( 'board-members', @@ -345,7 +353,7 @@ function ecosystemCards(seed: ProjectDetailSeed, projectName: string, foundation ? { prefix: `Your organization holds no board seats in ${foundation}.`, emphasis: '', suffix: '' } : { prefix: 'Our company employs ', - emphasis: `${eco.boardMembers} board ${eco.boardMembers === 1 ? 'member' : 'members'}`, + emphasis: `${eco.boardMembers} board ${plural(eco.boardMembers, 'member', 'members')}`, suffix: ` for ${foundation}.`, } ), From 1cc1b88dd3466df835c05ff517701046110ba832 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 6 Jul 2026 09:47:51 -0400 Subject: [PATCH 80/80] fix(review): address PR #1028 review feedback Address review comments from copilot[bot]: - packages/shared/src/constants/org-lens-project-detail.constants.ts: align PD_HEALTH_TAG.healthy severity to 'info' to match HEALTH_SCORE_SEVERITY.healthy on the Org Lens Projects page, so the "Healthy" badge renders the same color across both pages (per copilot[bot]) - apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts: add assertProjectSlug validation mirroring assertFoundationSlug in org-lens-memberships.controller.ts, hardening the projectSlug path param against malformed input before the real Snowflake backend replaces the fixture lookup (per copilot[bot]) Resolves 2 review threads. Signed-off-by: daniel qualls --- .../org-lens-project-detail.controller.ts | 17 ++++++++++++++++- .../org-lens-project-detail.constants.ts | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts b/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts index 6c930ae22..99d162016 100644 --- a/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts +++ b/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts @@ -3,6 +3,9 @@ import { NextFunction, Request, Response } from 'express'; +import { FOUNDATION_ID_PATTERN } from '@lfx-one/shared/constants'; + +import { ServiceValidationError } from '../errors'; import { assertOrgUid } from '../helpers/org-uid.helper'; import { logger } from '../services/logger.service'; import { OrgLensProjectDetailService } from '../services/org-lens-project-detail.service'; @@ -29,8 +32,9 @@ export class OrgLensProjectDetailController { try { assertOrgUid(orgUid, 'get_org_lens_project_detail'); + this.assertProjectSlug(projectSlug, 'get_org_lens_project_detail'); - const response = this.service.getProjectDetail(orgUid, orgName, projectSlug ?? ''); + const response = this.service.getProjectDetail(orgUid, orgName, projectSlug); if (response === null) { logger.success(req, 'get_org_lens_project_detail', startTime, { @@ -55,4 +59,15 @@ export class OrgLensProjectDetailController { next(error); } } + + // FOUNDATION_ID_PATTERN is the general SSR path-param validator (`[A-Za-z0-9-]{1,64}`); it also + // covers the project slug shape, so it is reused here for the slug-keyed detail route. + private assertProjectSlug(projectSlug: string | undefined, operation: string): asserts projectSlug is string { + if (!projectSlug || typeof projectSlug !== 'string') { + throw ServiceValidationError.forField('projectSlug', 'projectSlug path parameter is required', { operation }); + } + if (!FOUNDATION_ID_PATTERN.test(projectSlug)) { + throw ServiceValidationError.forField('projectSlug', 'Invalid projectSlug format', { operation }); + } + } } diff --git a/packages/shared/src/constants/org-lens-project-detail.constants.ts b/packages/shared/src/constants/org-lens-project-detail.constants.ts index baa821719..34b7d70c1 100644 --- a/packages/shared/src/constants/org-lens-project-detail.constants.ts +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -20,10 +20,10 @@ export const PD_VALID_METRICS: ReadonlySet = new Set = new Set(['1y', '2y', 'all']); -/** Hero health badge → lfx-tag severity (green Excellent / amber Healthy / red At Risk). */ +/** Hero health badge → lfx-tag severity (green Excellent / blue Healthy / red At Risk), matching HEALTH_SCORE_SEVERITY on the Org Lens Projects page. */ export const PD_HEALTH_TAG: Record = { excellent: { label: 'Excellent', severity: 'success' }, - healthy: { label: 'Healthy', severity: 'warn' }, + healthy: { label: 'Healthy', severity: 'info' }, 'at-risk': { label: 'At Risk', severity: 'danger' }, };