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..0187d934c --- /dev/null +++ b/apps/lfx-one/e2e/org-project-detail.spec.ts @@ -0,0 +1,131 @@ +// 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 ?metric= persistence, + * Activity Count hiding the band tags, 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 server-side demo fixtures + * (server/services/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 page.getByTestId('project-detail-tab-pd-leaderboards').click(); + 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-technical')).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-technical')).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 — leaderboards', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + }); + + 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(); + await expect(page.getByTestId('project-detail-trend-group')).toBeVisible(); + }); + + 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('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.locator('[data-test="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'); + }); +}); + +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 3938f9cb6..5a8da16a0 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -113,6 +113,16 @@ 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.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..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 @@ -91,11 +91,14 @@

@for (project of row.projects; track project.projectId) { - + @for (tab of tabs(); track tab.id) { + + } + + + +
+ @if (metricOptions(); as opts) { +
+ @for (opt of opts; track opt.id) { + + } +
+ } +
+ @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..2da3bd30e --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts @@ -0,0 +1,49 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { isPlatformBrowser } from '@angular/common'; +import { Component, ElementRef, inject, input, output, PLATFORM_ID, viewChildren } from '@angular/core'; +import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces'; + +@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 { + private readonly tabBtns = viewChildren>('tabBtn'); + + 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 }[]>(); + /** 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(); + public readonly timeRangeChange = output(); + public readonly metricChange = 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()[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 new file mode 100644 index 000000000..f55f72bcf --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html @@ -0,0 +1,547 @@ + + + +
+ @if (!hasCompany()) { + + } @else { + @switch (pageState()) { + @case ('loading') { +
+ + + + +
+ } + + @case ('error') { + + + } + + @case ('notFound') { + + + } + + @default { + @if (hero(); as h) { + + + + + +
+ +
+
+ @if (h.logoUrl) { + + } @else { + + } +

{{ h.projectName }}

+
+

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

+
+ + +
+
+ First commit + {{ firstCommitLabel() }} +
+
+ + Software value + + + {{ 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) { + + } +
+
+
+ + +
+
+

Our Ecosystem Influence

+ @if (ecosystemBandMeta(); as meta) { + + + {{ meta.label }} + + } +
+
+ + +
+
+
+
+ @for (card of ecosystemCards(); track card.key) { + + } +
+
+
+
+ } + @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 { + + } + + +
+
+ } +
+ + +
+
+
+
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) { + + } @else { + + } + {{ row.orgName }} + @if (row.isViewingOrg) { + You + } +
+ + + @if (isActivityMode()) { + {{ row.activityLabel }} + } @else { + + } + + +
+
+ } +
+
+ + +
+
+

Influence Trend

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

No data available.

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

{{ card.title }}

+

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

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

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

+

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

+
+ + @if (cardDetail(); as detail) { + +
+ + + + + + + + + + + + + + + + + + +
Definition + {{ detail.definition.totalType === 'average' ? 'Average for this project' : 'Total count for this project' }} + Data source
{{ 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 rowKey(dataRow)) { + + @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 new file mode 100644 index 000000000..46c1e9392 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts @@ -0,0 +1,679 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +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'; +import { + BAND_CHIP_CLASS, + BAND_SIGNAL_FILL, + BAND_SIGNAL_FILL_LIGHT, + BAND_SIGNAL_RANK, + PD_BAND_TAG, + PD_DEFAULT_METRIC, + PD_DEFAULT_TAB, + PD_VALID_TABS, + PD_DEFAULT_TIME_RANGE, + PD_HEALTH_TAG, + lfxColors, + 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, + LeaderboardDimension, + OrgLensCardDetailRow, + OrgLensCardDetailSection, + OrgLensLeaderboardMetric, + OrgLensLeaderboardTimeRange, + OrgLensProjectBand, + OrgLensProjectDetailPageState, + OrgLensProjectDetailResponse, + OrgLensProjectDetailTab, + OrgLensProjectInfluenceCard, +} from '@lfx-one/shared/interfaces'; +import { parseLocalDateString } from '@lfx-one/shared/utils'; +import type { MenuItem } from 'primeng/api'; +import { DrawerModule } from 'primeng/drawer'; +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, 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 { + 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), 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', + imports: [ + NgTemplateOutlet, + ReactiveFormsModule, + BreadcrumbComponent, + ChartComponent, + EmptyStateComponent, + InputTextComponent, + OrgProjectDetailTabBarComponent, + TableComponent, + TagComponent, + DrawerModule, + InputTextModule, + SkeletonModule, + TooltipModule, + ], + templateUrl: './org-project-detail.component.html', +}) +export class OrgProjectDetailComponent { + private readonly techTrackRef = viewChild>('technicalTrack'); + private readonly ecoTrackRef = viewChild>('ecosystemTrack'); + + 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); + + 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 selectedCard = signal(null); + protected readonly drawerOpen = signal(false); + + protected readonly cardDetail = computed(() => { + const card = this.selectedCard(); + if (!card) return null; + return this.detail()?.cardDetails?.[card.key] ?? 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()); + protected readonly hasCompany = computed(() => !!this.accountContext.selectedAccount().uid); + + // 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 ? 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)); + protected readonly logoInitials = computed(() => this.initialsFor(this.hero()?.projectName ?? '')); + + // 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 ? bandForScore(scores.technical) : null; + }); + protected readonly ecosystemBand = computed(() => { + 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: 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: 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(() => { + return (this.detail()?.technical ?? []).map((card) => this.toInfluenceCard(card, lfxColors.blue[500], 'technical')); + }); + protected readonly ecosystemCards = computed(() => { + 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. + 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'; + if (this.timeRange() === 'all') return 'Activity (All time)'; + return `Activity (${PD_TIME_RANGE_MONTHS[this.timeRange()]}mo)`; + }); + 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 }), + }); + 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())); + + // 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(); + + // 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() { + 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(() => ({ + 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([], { + relativeTo: this.route, + queryParams: { tab: tab === PD_DEFAULT_TAB ? null : tab }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected retry(): void { + this.retryTrigger.update((v) => v + 1); + } + + protected setMetric(metric: OrgLensLeaderboardMetric): void { + if (this.metric() === metric) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { metric: metric === PD_DEFAULT_METRIC ? null : metric }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected setTimeRange(range: OrgLensLeaderboardTimeRange): void { + if (this.timeRange() === range) return; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { range: range === PD_DEFAULT_TIME_RANGE ? null : range }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected openCardDetail(card: InfluenceCardVm): void { + this.selectedCard.set(card); + this.drawerOpen.set(true); + } + + protected closeCardDetail(): void { + this.drawerOpen.set(false); + } + + /** 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' }); + } + + /** 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('|'); + } + + protected onTrackScroll(el: HTMLElement, track: 'tech' | 'eco'): void { + this.refreshArrows(el, track === 'tech'); + } + + private initMetric(): OrgLensLeaderboardMetric { + const raw = this.queryParamMap().get('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 && PD_VALID_TIME_RANGES.has(raw) ? (raw as OrgLensLeaderboardTimeRange) : PD_DEFAULT_TIME_RANGE; + } + + /** + * Rank the leaderboard for one dimension (technical / ecosystem), then apply the search filter. + * Returns all matching rows — the paginator handles slicing. + */ + private buildBoard(dimension: LeaderboardDimension, search: string) { + 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 = isActivity ? null : PD_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 ?? 'secondary', + isViewingOrg: entry.row.isViewingOrg, + }; + }); + const query = search.trim().toLowerCase(); + return query ? ranked.filter((r) => r.orgName.toLowerCase().includes(query)) : ranked; + } + + private initActiveTab(): OrgLensProjectDetailTab { + const raw = this.queryParamMap().get('tab'); + return raw && PD_VALID_TABS.has(raw) ? (raw as OrgLensProjectDetailTab) : PD_DEFAULT_TAB; + } + + private initDetailStream(): Observable { + 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); + + 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, orgName, projectSlug]) => { + return this.detailService.getProjectDetail(orgUid, orgName, projectSlug).pipe( + catchError((err: unknown) => { + console.error('[OrgProjectDetail] failed to load project detail', err); + this.fetchError.set(true); + this.fetchLoading.set(false); + return of(null); + }) + ); + }), + tap((response) => { + this.detail.set(response); + this.searchForm.reset({ technical: '', ecosystem: '' }); + this.techSearch.set(''); + this.ecoSearch.set(''); + 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'; + } + + private initBreadcrumb(): MenuItem[] { + const hero = this.hero(); + 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: 'long' }); + } catch { + return dateString; + } + } + + 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 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 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 '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + + /** 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 { + 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%)'; + + 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'; + }; + } + + private buildLineAreaCardOptions(valueSuffix = ''): ChartOptions { + const external = this.buildExternalTooltipFn(valueSuffix) 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 } }, + }; + } + + private buildBarCardOptions(valueSuffix = ''): ChartOptions { + const external = this.buildExternalTooltipFn(valueSuffix) 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-merge-time', 'board-members'].includes(key)) return 'line'; + return 'area'; + } + + /** 36 trailing short-month labels (oldest → newest) — sliced to the active time range as needed. */ + private buildMonthLabels(): string[] { + const out: string[] = []; + const now = new Date(); + for (let i = 35; i >= 0; i--) { + out.push(new Date(now.getFullYear(), now.getMonth() - i, 1).toLocaleDateString('en-US', { month: 'short' })); + } + return out; + } + + 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(-sparkline.length); + return { + key: card.key, + title: card.label, + scopeLabel: card.scopeLabel, + hasData: sparkline.length > 0, + chartType: (variant === 'bar' ? 'bar' : 'line') as ChartType, + chartData: this.buildCardChartData(sparkline, projectSparkline, colorHex, variant, labels), + chartOptions: variant === 'bar' ? this.buildBarCardOptions(valueSuffix) : this.buildLineAreaCardOptions(valueSuffix), + valueSuffix, + caption: card.caption, + statLabel: card.caption.suffix.trim().replace(/\.$/, ''), + testId: `project-detail-${group}-card-${card.key}`, + }; + } + + private buildCardChartData( + series: number[], + projectSeries: number[], + colorHex: string, + variant: 'area' | 'bar' | 'line', + labels: string[] + ): ChartData { + if (variant === 'bar') { + return { + labels, + 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: fill ? colorHex + '33' : 'transparent', + fill: fill ? 'origin' : false, + tension: 0.4, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 0, + }, + ]; + 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: 0, + }); + } + return { labels, datasets }; + } + + /** + * 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 = PD_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 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%. + const rawSeries: number[][] = entries.map((entry) => 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))); + + // 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 = PD_STACKED_PALETTE[rankIdx] ?? lfxColors.gray[300]; + return { + label: item.entry.name, + data: item.pct, + backgroundColor: color + '99', + borderColor: color, + borderWidth: 1.5, + fill: 'stack', + tension: 0.3, + pointRadius: 0, + pointHoverRadius: 3, + }; + }); + + return { labels, datasets }; + } + + private buildStackedTrendOptions(): ChartOptions { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: true, position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 16 } }, + 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, min: 0, max: 100, ticks: { maxTicksLimit: 6, callback: (v) => `${v}%` } }, + }, + }; + } + + /** + * 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[] { + // [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 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); + }); + } +} 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..d01a33498 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-project-detail.service.ts @@ -0,0 +1,28 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import type { OrgLensProjectDetailResponse } from '@lfx-one/shared/interfaces'; +import { catchError, Observable, of, throwError } from 'rxjs'; + +/** + * 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 { + 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..99d162016 --- /dev/null +++ b/apps/lfx-one/src/server/controllers/org-lens-project-detail.controller.ts @@ -0,0 +1,73 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +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'; + +/** 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 rawOrgName = typeof req.query['orgName'] === 'string' ? req.query['orgName'].trim() : ''; + const orgName = rawOrgName || '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'); + this.assertProjectSlug(projectSlug, '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.setHeader('Cache-Control', 'no-store'); + 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); + } + } + + // 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/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/server/services/org-lens-project-detail.demo-data.ts b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts new file mode 100644 index 000000000..a54f20462 --- /dev/null +++ b/apps/lfx-one/src/server/services/org-lens-project-detail.demo-data.ts @@ -0,0 +1,793 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import type { + OrgLensCardDetailCell, + OrgLensCardDetailRow, + OrgLensCardDetailSection, + OrgLensProjectDetailResponse, + OrgLensProjectHealth, + OrgLensProjectInfluenceCard, + OrgLensProjectLeaderboardRow, +} 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; + 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: 'Kubernetes (K8s) is an open-source system for automating deployment, scaling, and management of containerized applications.', + foundationLabel: 'Cloud Native Computing Foundation', + health: 'excellent', + 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 }, + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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 }, + }, +}; + +/** 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 []; + 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 pctStr(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +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; + suffix: string; +} + +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; + 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.' } + : { + prefix: 'Our company employs ', + emphasis: `${org.maintainers}`, + suffix: ` ${plural(org.maintainers, 'maintainer', 'maintainers')} for this project.`, + } + ), + 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), 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), 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, org.prs === 0 ? [] : ramp(100 - seed.influence.technical, 0.85, 1), projMergeTime, { + prefix: 'PRs merged ', + emphasis: `${mergeSlower}% slower`, + suffix: ' than average.', + }), + ]; +} + +/** 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; + // 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( + 'collaboration', + '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.' } + ), + card( + 'meeting-attendance', + '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 ${plural(eco.meetingAttendance, 'meeting', 'meetings')}.`, + } + ), + card( + 'board-members', + '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: '' } + : { + prefix: 'Our company employs ', + emphasis: `${eco.boardMembers} board ${plural(eco.boardMembers, 'member', 'members')}`, + suffix: ` for ${foundation}.`, + } + ), + card( + 'committee-members', + '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), 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), 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), 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), 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), ramp(certifiedPct * 100 * 0.8, 0.72, 2), { + prefix: 'Employees make up ', + emphasis: pctStr(certifiedPct), + suffix: ' of all certified individuals.', + }), + ]; +} + +/** + * 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 }, +]; + +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; +} + +function leaderboard(seed: ProjectDetailSeed, orgName: string): OrgLensProjectLeaderboardRow[] { + // 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 rows: OrgLensProjectLeaderboardRow[] = COMPETITORS.map((c) => { + const combined = round1(c.strength * topScore); + return { + orgName: c.name, + 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), + 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 { + if (series.length === 0) return 0; + const start = series[0]; + 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`, +}; + +// --------------------------------------------------------------------------- +// 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 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 { + const name = pool[i % pool.length]; + return { person: { name, initials: personInitials(name) } }; +} + +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 + * the viewing-org leaderboard row. + */ +export function getDemoProjectDetail(orgUid: string, orgName: string, projectSlug: string): OrgLensProjectDetailResponse | null { + if (!Object.hasOwn(SEEDS, projectSlug)) return null; + const seed = SEEDS[projectSlug]; + + return { + accountId: orgUid, + projectSlug, + hero: { + projectName: seed.name, + description: seed.description, + logoUrl: PROJECT_LOGO_URLS[projectSlug] ?? '', + lfxInsightsUrl: `https://insights.linuxfoundation.org/project/${projectSlug}`, + firstCommit: seed.firstCommit, + softwareValueUsd: seed.softwareValueUsd, + health: seed.health, + foundationLabel: seed.foundationLabel, + }, + technical: technicalCards(seed), + ecosystem: ecosystemCards(seed, seed.name, seed.foundationLabel), + leaderboard: leaderboard(seed, orgName), + cardDetails: buildCardDetails(seed), + }; +} 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); + } +} 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/constants/index.ts b/packages/shared/src/constants/index.ts index 25ec0987f..f431347b4 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -80,5 +80,5 @@ export * from './mktg-os-agents.constants'; export * from './project-context.constants'; export * from './project-staff.constants'; - +export * from './org-lens-project-detail.constants'; export * from './org-meetings.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..34b7d70c1 --- /dev/null +++ b/packages/shared/src/constants/org-lens-project-detail.constants.ts @@ -0,0 +1,92 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { + OrgLensLeaderboardMetric, + OrgLensLeaderboardTimeRange, + OrgLensProjectBand, + OrgLensProjectDetailTab, + OrgLensProjectHealth, + TagSeverity, +} from '../interfaces'; +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 PD_DEFAULT_METRIC: OrgLensLeaderboardMetric = 'influence'; +export const PD_VALID_METRICS: ReadonlySet = new Set(['influence', 'activity']); + +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 / 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: 'info' }, + 'at-risk': { label: 'At Risk', severity: 'danger' }, +}; + +/** Leaderboard band chip → lfx-tag severity. */ +export const PD_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 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 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 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 PD_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/index.ts b/packages/shared/src/interfaces/index.ts index 3d5ebcd42..a9259a584 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..8bf2a0a27 --- /dev/null +++ b/packages/shared/src/interfaces/org-lens-project-detail.interface.ts @@ -0,0 +1,147 @@ +// 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). + */ + +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'; + +/** Leaderboard band per the markup-mu methodology (Boysel et al.). Strongest → weakest. */ +export type OrgLensProjectBand = 'leading' | 'contributing' | 'participating' | 'non-lf'; + +/** 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 Band column. */ +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 = '1y' | '2y' | 'all'; + +/** 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; + 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; +} + +/** + * 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 OrgLensProjectInfluenceCard { + key: string; + label: string; + /** 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[]; + /** 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 }; +} + +/** 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 }; + +/** 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 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 { + orgName: string; + /** Org logo URL; empty string falls back to initials. */ + orgLogoUrl: string; + /** 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; + /** 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: OrgLensProjectInfluenceCard[]; + ecosystem: OrgLensProjectInfluenceCard[]; + /** 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. */ +export type OrgLensProjectDetailPageState = 'loading' | 'error' | 'notFound' | 'ready';