From 0c19d428f1f793ae94ea88ae1ef870bdfa10e818 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Mon, 8 Jun 2026 16:30:52 -0600 Subject: [PATCH 01/12] feat(org-lens): add projects page with influence summary Adds the Org Lens Projects page (`/org/projects`): header + freshness label, Workspace/Foundation/Employee filters, Export to CSV + Find project toolbar, the 9-column projects table with health/influence columns, avatar stacks, row kebab, and URL-persisted sort/filter/ pagination (LFXV2-1883). Adds the Influence Summary section above the table with Most Influential/Gains/Decreases pill tabs and ranked project cards (LFXV2-1884). Data is served from a demo-fixture seam (OrgLensProjectsService) so the real Snowflake/LFX Insights integration can drop in later without component changes. Replaces the /org/projects placeholder route. Signed-off-by: daniel qualls --- apps/lfx-one/src/app/app.routes.ts | 2 +- .../influence-summary.component.html | 106 ++++ .../influence-summary.component.ts | 140 ++++++ .../org-projects/org-projects.component.html | 242 +++++++++ .../org-projects/org-projects.component.ts | 469 ++++++++++++++++++ .../services/org-lens-projects.demo-data.ts | 292 +++++++++++ .../services/org-lens-projects.service.ts | 30 ++ packages/shared/src/constants/index.ts | 1 + .../constants/org-lens-projects.constants.ts | 108 ++++ packages/shared/src/interfaces/index.ts | 3 + .../interfaces/org-lens-projects.interface.ts | 124 +++++ packages/shared/src/utils/file.utils.ts | 43 ++ 12 files changed, 1559 insertions(+), 1 deletion(-) create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html create mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts create mode 100644 apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts create mode 100644 apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts create mode 100644 packages/shared/src/constants/org-lens-projects.constants.ts create mode 100644 packages/shared/src/interfaces/org-lens-projects.interface.ts diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index 33a410851..4a3375ee5 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -111,7 +111,7 @@ export const routes: Routes = [ { path: 'projects', data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' }, - loadComponent: loadOrgPlaceholderPage, + loadComponent: () => import('./modules/dashboards/org/org-projects/org-projects.component').then((m) => m.OrgProjectsComponent), }, { path: 'roi', diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html new file mode 100644 index 000000000..c3a385238 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html @@ -0,0 +1,106 @@ + + + +
+ +
+

Influence Summary

+

Top 3 projects by influence, gains, and decreases over the past 12 months. Methodology: markup-mu (Boysel et al.).

+
+ + + + + + + + @if (error()) { +
+ +

Couldn't load the influence summary.

+ +
+ } @else if (loading()) { +
+ @for (slot of skeletonCards; track $index) { +
+
+
+
+
+ } +
+ } @else if (hasNoQualifying()) { +
+ + @if (activeMode() === 'influential') { +

Add more projects to your workspace to see your most influential projects here.

+ } @else { +

Not enough 12-month history to compute gains/decreases yet. Check back next month.

+ } +
+ } @else { +
+ @for (card of cards(); track card.project.slug) { +
+ +
+ + +
+ + +
+ + +
+

{{ card.primaryMetric }}

+ @if (activeMode() === 'influential') { +
+ + +
+ } @else { +
+ + +
+ } +
+ + +
+
+ {{ card.project.maintainers.length }} + Maintainers +
+
+ {{ card.project.contributors.length }} + Contributors +
+
+ {{ card.project.commits1y | number }} + 1y Commits +
+
+ + + +
+ } + + + @if (activeMode() === 'influential') { + @for (slot of fillerSlots(); track $index) { +
+ +

Add more projects to your workspace to see more here.

+
+ } + } +
+ } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts new file mode 100644 index 000000000..1195339f2 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts @@ -0,0 +1,140 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { DecimalPipe } from '@angular/common'; +import { Component, computed, inject, input, output, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + DEFAULT_INFLUENCE_SUMMARY_MODE, + HEALTH_SCORE_LABELS, + HEALTH_SCORE_SEVERITY, + INFLUENCE_BAND_LABELS, + INFLUENCE_BAND_SEVERITY, + INFLUENCE_SUMMARY_CARD_COUNT, + INFLUENCE_TREND_COLOR, + ORG_PROJECTS_INFLUENCE_TABS, + VALID_INFLUENCE_SUMMARY_MODES, +} from '@lfx-one/shared/constants'; +import type { HealthScore, InfluenceBand, InfluenceSummaryCard, InfluenceSummaryMode, OrgLensProject, TagSeverity } from '@lfx-one/shared/interfaces'; + +import { AvatarComponent } from '@components/avatar/avatar.component'; +import { ButtonComponent } from '@components/button/button.component'; +import { CardTabsBarComponent } from '@components/card-tabs-bar/card-tabs-bar.component'; +import { ChartComponent } from '@components/chart/chart.component'; +import { TagComponent } from '@components/tag/tag.component'; + +@Component({ + selector: 'lfx-influence-summary', + imports: [AvatarComponent, ButtonComponent, CardTabsBarComponent, ChartComponent, DecimalPipe, TagComponent], + templateUrl: './influence-summary.component.html', +}) +export class InfluenceSummaryComponent { + // Inputs / outputs + public readonly projects = input([]); + public readonly loading = input(false); + public readonly error = input(false); + public readonly retryRequested = output(); + + // Private injections + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + // Configuration + protected readonly tabs = ORG_PROJECTS_INFLUENCE_TABS.map((tab) => ({ id: tab.id, label: tab.label })); + protected readonly skeletonCards = Array.from({ length: INFLUENCE_SUMMARY_CARD_COUNT }); + // Minimal Chart.js line config for the card sparkline (no axes, points, legend, or tooltip). + protected readonly sparklineOptions = { + responsive: true, + maintainAspectRatio: false, + elements: { point: { radius: 0 }, line: { borderWidth: 2, tension: 0.4 } }, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + }; + + // Computed / toSignal + private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap }); + protected readonly activeMode: Signal = computed(() => { + const raw = this.queryParamMap().get('influenceTab'); + return raw && VALID_INFLUENCE_SUMMARY_MODES.has(raw as InfluenceSummaryMode) ? (raw as InfluenceSummaryMode) : DEFAULT_INFLUENCE_SUMMARY_MODE; + }); + protected readonly cards: Signal = computed(() => this.buildCards(this.projects(), this.activeMode())); + /** True when the active mode has no qualifying projects at all (drives insufficient-history copy). */ + protected readonly hasNoQualifying = computed(() => !this.loading() && !this.error() && this.cards().length === 0); + /** Empty slots to backfill the grid when fewer than 3 cards qualify. */ + protected readonly fillerSlots = computed(() => Array.from({ length: Math.max(0, INFLUENCE_SUMMARY_CARD_COUNT - this.cards().length) })); + + // Protected methods + protected switchMode(modeId: string): void { + if (!VALID_INFLUENCE_SUMMARY_MODES.has(modeId as InfluenceSummaryMode) || modeId === this.activeMode()) { + return; + } + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { influenceTab: modeId === DEFAULT_INFLUENCE_SUMMARY_MODE ? null : modeId }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected openDetail(project: OrgLensProject): void { + // Project Detail sub-page is delivered in LFXV2-1885; navigation target wired there. + void this.router.navigate([], { relativeTo: this.route, queryParams: { project: project.slug }, queryParamsHandling: 'merge' }); + } + + protected bandLabel(band: InfluenceBand): string { + return INFLUENCE_BAND_LABELS[band]; + } + protected bandSeverity(band: InfluenceBand): TagSeverity { + return INFLUENCE_BAND_SEVERITY[band]; + } + protected healthLabel(health: HealthScore): string { + return HEALTH_SCORE_LABELS[health]; + } + protected healthSeverity(health: HealthScore): TagSeverity { + return HEALTH_SCORE_SEVERITY[health]; + } + protected driverSeverity(): TagSeverity { + return this.activeMode() === 'gains' ? 'success' : 'danger'; + } + protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { + return { + labels: project.trend.series.map((_, i) => String(i)), + datasets: [{ data: project.trend.series, borderColor: INFLUENCE_TREND_COLOR[project.trend.direction], fill: false }], + }; + } + + // Private helpers + private buildCards(projects: OrgLensProject[], mode: InfluenceSummaryMode): InfluenceSummaryCard[] { + if (mode === 'gains') { + return projects + .filter((p) => p.priorYearScore !== 0) + .sort((a, b) => this.delta(b) - this.delta(a) || a.name.localeCompare(b.name)) + .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) + .map((project) => ({ project, primaryMetric: `${this.signed(this.delta(project))} points (1y)` })); + } + if (mode === 'decreases') { + return projects + .filter((p) => p.influenceScore !== 0) + .sort((a, b) => this.delta(a) - this.delta(b) || a.name.localeCompare(b.name)) + .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) + .map((project) => ({ project, primaryMetric: `${this.signed(this.delta(project))} points (1y)` })); + } + // Most Influential: current score desc, tie-break 1y delta desc, then name asc. + return projects + .filter((p) => p.influenceScore > 0) + .sort((a, b) => b.influenceScore - a.influenceScore || b.trend.deltaPct - a.trend.deltaPct || a.name.localeCompare(b.name)) + .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) + .map((project) => ({ project, primaryMetric: `Influence score: ${project.influenceScore} (${INFLUENCE_BAND_LABELS[project.technicalInfluence]})` })); + } + + private delta(project: OrgLensProject): number { + return Math.round((project.influenceScore - project.priorYearScore) * 10) / 10; + } + + private signed(value: number): string { + return value > 0 ? `+${value}` : String(value); + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html new file mode 100644 index 000000000..2bffbfed0 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -0,0 +1,242 @@ + + + +
+ +
+
+

+ @if (companyName()) { + Projects — {{ companyName() }} + } @else { + Projects + } +

+ @if (freshnessLabel()) { + {{ freshnessLabel() }} + } +
+

+ The open source projects your organization influences, contributes to, and depends on. +

+
+ + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+ @if (error()) { +
+ +

Something went wrong loading your projects.

+ +
+ } @else if (!loading() && totalRecords() === 0) { + + } @else { + + + + + + + + + + + + + + + + + + + + + + Maintainers + Contributors + Participants + Actions + + + + + + + + + + + + + + + + + + +
+ + + {{ project.trend.deltaPct > 0 ? '+' : '' }}{{ project.trend.deltaPct }}% + +
+ + + + + + + + + + +
+
+ } +
+
+
+ + + + + + + @if (people.length) { +
+
+ @for (person of stackVisible(people); track person.id) { + + + + } + @if (stackOverflow(people) > 0) { + + +{{ stackOverflow(people) }} + + } +
+ {{ people.length }} +
+ } @else { + + } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts new file mode 100644 index 000000000..59fa62d83 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -0,0 +1,469 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, inject, signal, Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + DEFAULT_ORG_PROJECTS_PAGE_SIZE, + DEFAULT_ORG_PROJECTS_SORT_DIR, + DEFAULT_ORG_PROJECTS_SORT_FIELD, + DEFAULT_ORG_PROJECTS_WORKSPACE_ID, + HEALTH_SCORE_LABELS, + HEALTH_SCORE_SEVERITY, + INFLUENCE_BAND_LABELS, + INFLUENCE_BAND_RANK, + INFLUENCE_BAND_SEVERITY, + INFLUENCE_TREND_COLOR, + ORG_PROJECTS_AVATAR_STACK_LIMIT, + ORG_PROJECTS_PAGE_SIZE_OPTIONS, + ORG_PROJECTS_WORKSPACE_OPTIONS, + VALID_ORG_PROJECTS_SORT_FIELDS, + VALID_ORG_PROJECTS_WORKSPACE_IDS, +} from '@lfx-one/shared/constants'; +import type { + HealthScore, + InfluenceBand, + InfluenceTrendDirection, + OrgLensProject, + OrgLensProjectPerson, + OrgLensProjectsResponse, + OrgProjectsSortField, + OrgProjectsWorkspaceId, + SortDirection, + TagSeverity, +} from '@lfx-one/shared/interfaces'; +import { downloadCsv, formatRelativeTime } from '@lfx-one/shared/utils'; +import { MenuItem } from 'primeng/api'; +import { catchError, finalize, of, switchMap } from 'rxjs'; + +import { AvatarComponent } from '@components/avatar/avatar.component'; +import { ButtonComponent } from '@components/button/button.component'; +import { CardComponent } from '@components/card/card.component'; +import { ChartComponent } from '@components/chart/chart.component'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; +import { MenuComponent } from '@components/menu/menu.component'; +import { MultiSelectComponent } from '@components/multi-select/multi-select.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TableComponent } from '@components/table/table.component'; +import { TagComponent } from '@components/tag/tag.component'; +import { AccountContextService } from '@shared/services/account-context.service'; +import { OrgLensProjectsService } from '@shared/services/org-lens-projects.service'; + +import { InfluenceSummaryComponent } from './components/influence-summary/influence-summary.component'; + +const ALL_FOUNDATIONS = 'all'; + +@Component({ + selector: 'lfx-org-projects', + imports: [ + AvatarComponent, + ButtonComponent, + CardComponent, + ChartComponent, + EmptyStateComponent, + InfluenceSummaryComponent, + MenuComponent, + MultiSelectComponent, + NgTemplateOutlet, + SelectComponent, + TableComponent, + TagComponent, + ], + templateUrl: './org-projects.component.html', +}) +export class OrgProjectsComponent { + // Private injections + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly accountContext = inject(AccountContextService); + private readonly projectsService = inject(OrgLensProjectsService); + + // Configuration + protected readonly workspaceOptions = [...ORG_PROJECTS_WORKSPACE_OPTIONS]; + protected readonly pageSizeOptions = [...ORG_PROJECTS_PAGE_SIZE_OPTIONS]; + protected readonly avatarStackLimit = ORG_PROJECTS_AVATAR_STACK_LIMIT; + // Minimal Chart.js line config for the Influence Trend sparkline (no axes, points, legend, or tooltip). + protected readonly sparklineOptions = { + responsive: true, + maintainAspectRatio: false, + elements: { point: { radius: 0 }, line: { borderWidth: 2, tension: 0.4 } }, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + }; + + // Forms + protected readonly filterForm = new FormGroup({ + workspace: new FormControl(this.readWorkspaceFromUrl(), { nonNullable: true }), + foundation: new FormControl(this.route.snapshot.queryParamMap.get('foundation') ?? ALL_FOUNDATIONS, { nonNullable: true }), + employees: new FormControl(this.readEmployeesFromUrl(), { nonNullable: true }), + }); + + // Writable Signals + protected readonly loading = signal(false); + protected readonly error = signal(false); + /** Per-user workspace pin/hide state (client-only; never mutates the foundation catalog). */ + protected readonly pinnedSlugs = signal>(new Set()); + protected readonly hiddenSlugs = signal>(new Set()); + /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ + private readonly reload = signal(0); + /** Action menu items rebuilt per row when the kebab is opened. */ + protected rowMenuItems: MenuItem[] = []; + + // Computed / toSignal + private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap }); + private readonly formValue = toSignal(this.filterForm.valueChanges, { initialValue: this.filterForm.getRawValue() }); + private readonly response: Signal = this.initResponse(); + + protected readonly companyName = computed(() => this.accountContext.selectedAccount()?.accountName ?? ''); + protected readonly freshnessLabel = computed(() => { + const updatedAt = this.response()?.dataUpdatedAt; + return updatedAt ? `Data updated ${formatRelativeTime(new Date(updatedAt))}` : ''; + }); + + protected readonly sortField = computed(() => { + const raw = this.queryParamMap().get('sort'); + return raw && VALID_ORG_PROJECTS_SORT_FIELDS.has(raw as OrgProjectsSortField) ? (raw as OrgProjectsSortField) : DEFAULT_ORG_PROJECTS_SORT_FIELD; + }); + protected readonly sortDir = computed(() => (this.queryParamMap().get('dir') === 'asc' ? 'asc' : DEFAULT_ORG_PROJECTS_SORT_DIR)); + protected readonly pageSize = computed(() => { + const raw = Number(this.queryParamMap().get('size')); + return ORG_PROJECTS_PAGE_SIZE_OPTIONS.includes(raw) ? raw : DEFAULT_ORG_PROJECTS_PAGE_SIZE; + }); + protected readonly pageFirst = computed(() => { + const page = Math.max(1, Number(this.queryParamMap().get('page')) || 1); + return (page - 1) * this.pageSize(); + }); + + protected readonly workspaceId = computed(() => this.formValue().workspace ?? DEFAULT_ORG_PROJECTS_WORKSPACE_ID); + protected readonly foundationOptions = this.initFoundationOptions(); + protected readonly employeeOptions = this.initEmployeeOptions(); + + /** Workspace preset + foundation filter applied; shared by the table and the Influence Summary. */ + protected readonly filteredProjects = this.initFilteredProjects(); + /** `filteredProjects` ordered by the active sort (pinned rows float to the top). */ + protected readonly sortedProjects = this.initSortedProjects(); + protected readonly totalRecords = computed(() => this.sortedProjects().length); + + public constructor() { + // Filter changes (workspace / foundation / employees) write through to the URL and reset to page 1. + this.filterForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { + workspace: value.workspace === DEFAULT_ORG_PROJECTS_WORKSPACE_ID ? null : value.workspace, + foundation: value.foundation === ALL_FOUNDATIONS ? null : value.foundation, + employees: value.employees && value.employees.length ? value.employees.join(',') : null, + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + } + + // Public methods + public retry(): void { + this.reload.update((n) => n + 1); + } + + // Protected methods + protected toggleSort(field: OrgProjectsSortField): void { + const nextDir: SortDirection = this.sortField() === field && this.sortDir() === 'desc' ? 'asc' : 'desc'; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { sort: field === DEFAULT_ORG_PROJECTS_SORT_FIELD ? null : field, dir: nextDir === DEFAULT_ORG_PROJECTS_SORT_DIR ? null : nextDir, page: null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected onPage(event: { first?: number; rows?: number }): void { + const rows = event.rows ?? this.pageSize(); + const page = Math.floor((event.first ?? 0) / rows) + 1; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { page: page <= 1 ? null : page, size: rows === DEFAULT_ORG_PROJECTS_PAGE_SIZE ? null : rows }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected resetFilters(): void { + this.filterForm.reset({ workspace: DEFAULT_ORG_PROJECTS_WORKSPACE_ID, foundation: ALL_FOUNDATIONS, employees: [] }); + } + + protected sortIcon(field: OrgProjectsSortField): string { + if (this.sortField() !== field) { + return 'fa-light fa-sort'; + } + return this.sortDir() === 'asc' ? 'fa-light fa-sort-up' : 'fa-light fa-sort-down'; + } + + protected openFindProject(): void { + // +Find project opens an add-project modal whose internals ship in a separate ticket. + } + + protected openRowMenu(menu: MenuComponent, project: OrgLensProject, event: Event): void { + this.rowMenuItems = this.buildRowMenu(project); + menu.toggle(event); + } + + protected openDetail(project: OrgLensProject): void { + // Project Detail sub-page is delivered in LFXV2-1885; navigation target wired there. + void this.router.navigate([], { relativeTo: this.route, queryParams: { project: project.slug }, queryParamsHandling: 'merge' }); + } + + protected exportCsv(): void { + const rows = this.sortedProjects(); + if (!rows.length) { + return; + } + const header = [ + 'Project', + 'Foundation', + 'Health Score', + 'Technical Influence', + 'Ecosystem Influence', + 'Influence Trend (1y) %', + 'Our Maintainers', + 'Our Contributors', + 'Our Participants', + ]; + const body = rows.map((p) => [ + p.name, + p.foundation.name, + HEALTH_SCORE_LABELS[p.health], + INFLUENCE_BAND_LABELS[p.technicalInfluence], + INFLUENCE_BAND_LABELS[p.ecosystemInfluence], + p.trend.deltaPct, + p.maintainers.length, + p.contributors.length, + p.participants.length, + ]); + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const slug = this.response()?.orgSlug ?? 'org'; + downloadCsv(`org-lens-projects-${slug}-${date}.csv`, [header, ...body]); + } + + // Template display helpers + protected bandLabel(band: InfluenceBand): string { + return INFLUENCE_BAND_LABELS[band]; + } + protected bandSeverity(band: InfluenceBand): TagSeverity { + return INFLUENCE_BAND_SEVERITY[band]; + } + protected healthLabel(health: HealthScore): string { + return HEALTH_SCORE_LABELS[health]; + } + protected healthSeverity(health: HealthScore): TagSeverity { + return HEALTH_SCORE_SEVERITY[health]; + } + protected trendClass(direction: InfluenceTrendDirection): string { + if (direction === 'up') { + return 'text-emerald-600'; + } + if (direction === 'down') { + return 'text-red-600'; + } + return 'text-gray-500'; + } + protected stackVisible(people: OrgLensProjectPerson[]): OrgLensProjectPerson[] { + return people.slice(0, this.avatarStackLimit); + } + protected stackOverflow(people: OrgLensProjectPerson[]): number { + return Math.max(0, people.length - this.avatarStackLimit); + } + protected isPinned(slug: string): boolean { + return this.pinnedSlugs().has(slug); + } + protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { + return { + labels: project.trend.series.map((_, i) => String(i)), + datasets: [{ data: project.trend.series, borderColor: INFLUENCE_TREND_COLOR[project.trend.direction], fill: false }], + }; + } + + // Private initializers + private initResponse(): Signal { + const account$ = toObservable(computed(() => ({ account: this.accountContext.selectedAccount(), _reload: this.reload() }))); + return toSignal( + account$.pipe( + switchMap(({ account }) => { + const uid = account?.uid; + if (!uid) { + this.loading.set(false); + this.error.set(false); + return of(null); + } + this.loading.set(true); + this.error.set(false); + return this.projectsService.getProjects(uid, account?.accountName ?? '').pipe( + catchError(() => { + this.error.set(true); + return of(null); + }), + finalize(() => this.loading.set(false)) + ); + }) + ), + { initialValue: null } + ); + } + + private initFoundationOptions(): Signal<{ label: string; value: string }[]> { + return computed(() => { + const projects = this.response()?.projects ?? []; + const bySlug = new Map(); + for (const project of projects) { + bySlug.set(project.foundation.slug, project.foundation.name); + } + const options = [...bySlug.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)); + return [{ label: 'All Foundations', value: ALL_FOUNDATIONS }, ...options]; + }); + } + + private initEmployeeOptions(): Signal<{ label: string; value: string }[]> { + return computed(() => { + const projects = this.response()?.projects ?? []; + const byId = new Map(); + for (const project of projects) { + for (const person of [...project.maintainers, ...project.contributors, ...project.participants]) { + byId.set(person.id, person.name); + } + } + return [...byId.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)); + }); + } + + private initFilteredProjects(): Signal { + return computed(() => { + const all = this.response()?.projects ?? []; + const workspace = this.workspaceId(); + const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; + const hidden = this.hiddenSlugs(); + return all + .filter((p) => !hidden.has(p.slug)) + .filter((p) => this.matchesWorkspace(p, workspace)) + .filter((p) => foundation === ALL_FOUNDATIONS || p.foundation.slug === foundation); + }); + } + + private initSortedProjects(): Signal { + return computed(() => { + const projects = [...this.filteredProjects()]; + const field = this.sortField(); + const dir = this.sortDir(); + const pinned = this.pinnedSlugs(); + projects.sort((a, b) => { + const aPinned = pinned.has(a.slug); + const bPinned = pinned.has(b.slug); + if (aPinned !== bPinned) { + return aPinned ? -1 : 1; + } + return this.compareProjects(a, b, field, dir); + }); + return projects; + }); + } + + private compareProjects(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField, dir: SortDirection): number { + const primary = this.compareByField(a, b, field); + const directed = dir === 'asc' ? primary : -primary; + if (directed !== 0) { + return directed; + } + // Tie-break: Technical Influence band desc, then project name asc. + const bandTie = INFLUENCE_BAND_RANK[b.technicalInfluence] - INFLUENCE_BAND_RANK[a.technicalInfluence]; + return bandTie !== 0 ? bandTie : a.name.localeCompare(b.name); + } + + private compareByField(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField): number { + switch (field) { + case 'name': + return a.name.localeCompare(b.name); + case 'foundation': + return a.foundation.name.localeCompare(b.foundation.name); + case 'health': + return this.healthRank(a.health) - this.healthRank(b.health); + case 'technicalInfluence': + return INFLUENCE_BAND_RANK[a.technicalInfluence] - INFLUENCE_BAND_RANK[b.technicalInfluence]; + case 'ecosystemInfluence': + return INFLUENCE_BAND_RANK[a.ecosystemInfluence] - INFLUENCE_BAND_RANK[b.ecosystemInfluence]; + case 'influenceTrend': + return a.trend.deltaPct - b.trend.deltaPct; + default: + return 0; + } + } + + private healthRank(health: HealthScore): number { + if (health === 'excellent') { + return 2; + } + return health === 'healthy' ? 1 : 0; + } + + private matchesWorkspace(project: OrgLensProject, workspace: OrgProjectsWorkspaceId): boolean { + switch (workspace) { + case 'all-projects': + return true; + case 'most-influential': + return project.technicalInfluence === 'leading' || project.technicalInfluence === 'contributing'; + case 'where-we-lead': + return project.technicalInfluence === 'leading'; + case 'most-active': + default: + // Active = not archived (excludes the demo "Jenkins" archived row with score 0). + return project.influenceScore > 0; + } + } + + private buildRowMenu(project: OrgLensProject): MenuItem[] { + const pinned = this.isPinned(project.slug); + return [ + { + label: pinned ? 'Unpin from top' : 'Pin to top', + icon: pinned ? 'fa-light fa-thumbtack-slash' : 'fa-light fa-thumbtack', + command: () => this.togglePin(project.slug), + }, + { label: 'Open detail', icon: 'fa-light fa-arrow-up-right-from-square', command: () => this.openDetail(project) }, + { label: 'Add to workspace', icon: 'fa-light fa-plus', command: () => this.addToWorkspace() }, + { label: 'Hide from this workspace', icon: 'fa-light fa-eye-slash', command: () => this.hideFromWorkspace(project.slug) }, + ]; + } + + private togglePin(slug: string): void { + this.pinnedSlugs.update((set) => { + const next = new Set(set); + if (next.has(slug)) { + next.delete(slug); + } else { + next.add(slug); + } + return next; + }); + } + + private hideFromWorkspace(slug: string): void { + this.hiddenSlugs.update((set) => new Set(set).add(slug)); + } + + private addToWorkspace(): void { + // Add-to-workspace writes to the per-user workspace project list; CRUD flow is a separate ticket. + } + + private readWorkspaceFromUrl(): OrgProjectsWorkspaceId { + const raw = this.route.snapshot.queryParamMap.get('workspace'); + return raw && VALID_ORG_PROJECTS_WORKSPACE_IDS.has(raw as OrgProjectsWorkspaceId) ? (raw as OrgProjectsWorkspaceId) : DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + } + + private readEmployeesFromUrl(): string[] { + const raw = this.route.snapshot.queryParamMap.get('employees'); + return raw ? raw.split(',').filter(Boolean) : []; + } +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts new file mode 100644 index 000000000..bb83b826e --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -0,0 +1,292 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import type { InfluenceTrendDirection, OrgLensProject, OrgLensProjectFoundation, OrgLensProjectPerson, OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; + +/** + * Demo company data for the Org Lens Projects page (LFXV2-1883 / LFXV2-1884). + * + * Real company-data integration (Snowflake / LFX Insights) is a separate story; until + * then `OrgLensProjectsService` serves these fixtures. They are deliberately varied to + * exercise every band / health / trend state and the Influence Summary empty cases: + * one project has `priorYearScore: 0` (excluded from Most Gains) and one is archived + * with `influenceScore: 0` (excluded from Most Decreases). + */ + +// Empty avatar / logo URLs fall back to initials in the avatar/logo components — no network needed for demo. +function person(id: string, name: string): OrgLensProjectPerson { + return { id, name, avatarUrl: '' }; +} + +const CNCF: OrgLensProjectFoundation = { slug: 'cncf', name: 'CNCF', logoUrl: '' }; +const LF_AI: OrgLensProjectFoundation = { slug: 'lf-ai-data', name: 'LF AI & Data', logoUrl: '' }; +const LF_NETWORKING: OrgLensProjectFoundation = { slug: 'lf-networking', name: 'LF Networking', logoUrl: '' }; +const OPENSSF: OrgLensProjectFoundation = { slug: 'openssf', name: 'OpenSSF', logoUrl: '' }; +const CD_FOUNDATION: OrgLensProjectFoundation = { slug: 'cd-foundation', name: 'CD Foundation', logoUrl: '' }; + +function trendDirection(deltaPct: number): InfluenceTrendDirection { + if (deltaPct > 1) { + return 'up'; + } + return deltaPct < -1 ? 'down' : 'flat'; +} + +function trend(deltaPct: number, series: number[]): { deltaPct: number; direction: InfluenceTrendDirection; series: number[] } { + return { deltaPct, direction: trendDirection(deltaPct), series }; +} + +const DEMO_PROJECTS: OrgLensProject[] = [ + { + slug: 'kubernetes', + name: 'Kubernetes', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'leading', + influenceScore: 92.4, + priorYearScore: 78.2, + trend: trend(18.2, [78, 80, 83, 85, 88, 90, 92]), + maintainers: [person('p1', 'Ada Lovelace'), person('p2', 'Grace Hopper'), person('p3', 'Alan Turing'), person('p4', 'Linus Park'), person('p5', 'Mira Chen')], + contributors: [person('p6', 'Tom Reyes'), person('p7', 'Nina Patel'), person('p8', 'Omar Diaz'), person('p9', 'Sara Kim'), person('p10', 'Wei Zhang'), person('p11', 'Eve Stone')], + participants: [person('p12', 'Raj Singh'), person('p13', 'Lena Fox')], + commits1y: 48210, + changeDriver: { label: '+3 maintainers', direction: 'up' }, + }, + { + slug: 'prometheus', + name: 'Prometheus', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'contributing', + influenceScore: 84.1, + priorYearScore: 76.5, + trend: trend(9.9, [76, 77, 79, 80, 82, 83, 84]), + maintainers: [person('p14', 'Carlos Mota'), person('p15', 'Yuki Tanaka')], + contributors: [person('p16', 'Dana Cole'), person('p17', 'Ivan Petrov'), person('p18', 'Maya Rao')], + participants: [person('p19', 'Joel Park')], + commits1y: 12880, + changeDriver: { label: '+38% commits', direction: 'up' }, + }, + { + slug: 'envoy', + name: 'Envoy', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'leading', + ecosystemInfluence: 'contributing', + influenceScore: 80.7, + priorYearScore: 71.0, + trend: trend(13.7, [71, 72, 74, 76, 78, 79, 81]), + maintainers: [person('p20', 'Priya Nair'), person('p21', 'Hugo Blanc'), person('p22', 'Sven Olsen')], + contributors: [person('p23', 'Lily Brooks'), person('p24', 'Kofi Mensah')], + participants: [person('p25', 'Anya Volkov'), person('p26', 'Diego Ruiz'), person('p27', 'Fatima Zahra')], + commits1y: 9340, + changeDriver: { label: '+2 board seats', direction: 'up' }, + }, + { + slug: 'opentelemetry', + name: 'OpenTelemetry', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'contributing', + ecosystemInfluence: 'leading', + influenceScore: 79.3, + priorYearScore: 58.9, + trend: trend(34.6, [58, 62, 66, 70, 74, 77, 79]), + maintainers: [person('p28', 'Reza Karimi'), person('p29', 'Bo Andersson')], + contributors: [person('p30', 'Tara Singh'), person('p31', 'Max Weber'), person('p32', 'Iris Lin'), person('p33', 'Pavel Novak')], + participants: [person('p34', 'Noor Ali')], + commits1y: 15600, + changeDriver: { label: '+52% commits', direction: 'up' }, + }, + { + slug: 'argo', + name: 'Argo', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 68.5, + priorYearScore: 64.2, + trend: trend(6.7, [64, 65, 65, 66, 67, 68, 69]), + maintainers: [person('p35', 'Greta Hahn')], + contributors: [person('p36', 'Sam Otieno'), person('p37', 'Bianca Russo')], + participants: [person('p38', 'Kenji Mori'), person('p39', 'Aisha Bello')], + commits1y: 6120, + changeDriver: { label: '+1 maintainer', direction: 'up' }, + }, + { + slug: 'pytorch', + name: 'PyTorch', + logoUrl: '', + foundation: LF_AI, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'leading', + influenceScore: 88.0, + priorYearScore: 83.4, + trend: trend(5.5, [83, 84, 85, 86, 86, 87, 88]), + maintainers: [person('p40', 'Hannah Frost'), person('p41', 'Leo Marsh'), person('p42', 'Devi Suresh')], + contributors: [person('p43', 'Quinn Hale'), person('p44', 'Ravi Iyer'), person('p45', 'Selin Aydin'), person('p46', 'Tobias Weiss')], + participants: [person('p47', 'Mei Lin')], + commits1y: 21030, + changeDriver: { label: '+24% commits', direction: 'up' }, + }, + { + slug: 'onnx', + name: 'ONNX', + logoUrl: '', + foundation: LF_AI, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'participating', + influenceScore: 54.2, + priorYearScore: 57.8, + trend: trend(-6.2, [58, 57, 57, 56, 55, 55, 54]), + maintainers: [person('p48', 'Owen Clarke')], + contributors: [person('p49', 'Petra Vogel'), person('p50', 'Hassan Najjar')], + participants: [person('p51', 'Rita Costa')], + commits1y: 3420, + changeDriver: { label: '-22% commits', direction: 'down' }, + }, + { + slug: 'onap', + name: 'ONAP', + logoUrl: '', + foundation: LF_NETWORKING, + health: 'at-risk', + technicalInfluence: 'participating', + ecosystemInfluence: 'participating', + influenceScore: 41.6, + priorYearScore: 51.4, + trend: trend(-19.1, [51, 49, 48, 46, 44, 43, 42]), + maintainers: [person('p52', 'Gabe Lewis')], + contributors: [person('p53', 'Hana Suzuki')], + participants: [person('p54', 'Igor Pavlov'), person('p55', 'Joy Adeyemi')], + commits1y: 1880, + changeDriver: { label: '-1 maintainer', direction: 'down' }, + }, + { + slug: 'fd-io', + name: 'FD.io', + logoUrl: '', + foundation: LF_NETWORKING, + health: 'at-risk', + technicalInfluence: 'participating', + ecosystemInfluence: 'non-lf', + influenceScore: 33.9, + priorYearScore: 42.7, + trend: trend(-20.6, [43, 41, 40, 38, 36, 35, 34]), + maintainers: [], + contributors: [person('p56', 'Karl Schmidt')], + participants: [person('p57', 'Lucia Moreno')], + commits1y: 940, + changeDriver: { label: '-31% commits', direction: 'down' }, + }, + { + slug: 'sigstore', + name: 'Sigstore', + logoUrl: '', + foundation: OPENSSF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 62.8, + // No 12-month baseline (graduated mid-window) — excluded from Most Gains. + priorYearScore: 0, + trend: trend(0, [60, 61, 62, 62, 62, 63, 63]), + maintainers: [person('p58', 'Nadia Haddad'), person('p59', 'Oskar Lind')], + contributors: [person('p60', 'Pia Berg'), person('p61', 'Rohan Mehta')], + participants: [], + commits1y: 4510, + changeDriver: { label: 'New baseline', direction: 'flat' }, + }, + { + slug: 'in-toto', + name: 'in-toto', + logoUrl: '', + foundation: OPENSSF, + health: 'healthy', + technicalInfluence: 'participating', + ecosystemInfluence: 'participating', + influenceScore: 47.3, + priorYearScore: 44.9, + trend: trend(5.3, [44, 45, 45, 46, 46, 47, 47]), + maintainers: [person('p62', 'Sami Rahimi')], + contributors: [person('p63', 'Tess Howell')], + participants: [person('p64', 'Umar Faruk'), person('p65', 'Vera Klein'), person('p66', 'Will Tanaka')], + commits1y: 2210, + changeDriver: { label: '+12% commits', direction: 'up' }, + }, + { + slug: 'tekton', + name: 'Tekton', + logoUrl: '', + foundation: CD_FOUNDATION, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'participating', + influenceScore: 58.1, + priorYearScore: 60.9, + trend: trend(-4.6, [61, 60, 60, 59, 59, 58, 58]), + maintainers: [person('p67', 'Xena Pope')], + contributors: [person('p68', 'Yusuf Demir'), person('p69', 'Zoe Walsh')], + participants: [person('p70', 'Arman Yilmaz')], + commits1y: 3990, + changeDriver: { label: '-1 board seat', direction: 'down' }, + }, + { + slug: 'jenkins', + name: 'Jenkins', + logoUrl: '', + foundation: CD_FOUNDATION, + health: 'at-risk', + technicalInfluence: 'participating', + ecosystemInfluence: 'non-lf', + // Archived in our workspace — excluded from Most Decreases (current score 0). + influenceScore: 0, + priorYearScore: 38.2, + trend: trend(-42.0, [38, 30, 24, 16, 9, 4, 0]), + maintainers: [], + contributors: [], + participants: [person('p71', 'Bella North')], + commits1y: 120, + changeDriver: { label: 'Archived', direction: 'down' }, + }, + { + slug: 'spiffe-spire', + name: 'SPIFFE / SPIRE', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 66.0, + priorYearScore: 55.1, + trend: trend(19.8, [55, 57, 59, 61, 63, 65, 66]), + maintainers: [person('p72', 'Cody Frank'), person('p73', 'Dilara Kaya')], + contributors: [person('p74', 'Emil Larsson'), person('p75', 'Farah Saleh'), person('p76', 'Gus Romero')], + participants: [person('p77', 'Hye-jin Park'), person('p78', 'Ido Cohen')], + commits1y: 5230, + changeDriver: { label: '+2 maintainers', direction: 'up' }, + }, +]; + +/** Demo response for a single org. The org slug/name flow into the CSV export filename + header. */ +export function getDemoProjectsResponse(orgUid: string, orgName: string): OrgLensProjectsResponse { + return { + orgSlug: orgName ? orgName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : orgUid, + orgName: orgName || 'Your organization', + // Static demo build timestamp (~2h ago) so the freshness label renders a stable relative value. + dataUpdatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + projects: DEMO_PROJECTS.map((project) => ({ ...project })), + }; +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts new file mode 100644 index 000000000..44a08b079 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts @@ -0,0 +1,30 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Injectable } from '@angular/core'; +import type { OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; +import { delay, Observable, of } from 'rxjs'; + +import { getDemoProjectsResponse } from './org-lens-projects.demo-data'; + +/** Simulated network latency so the page exercises its loading skeletons. */ +const DEMO_LATENCY_MS = 450; + +/** + * Data seam for the Org Lens Projects page (LFXV2-1883 / LFXV2-1884). + * + * Currently returns demo company fixtures. Wiring the real Snowflake / LFX Insights + * backend (a separate story) only replaces this method body with an `HttpClient` call + * to `/api/orgs/:orgUid/lens/projects` — the response shape and every consumer stay the + * same. See `OrgLensTrainingService` for the eventual HTTP pattern. + */ +@Injectable({ + providedIn: 'root', +}) +export class OrgLensProjectsService { + public getProjects(orgUid: string, orgName: string): Observable { + return of(getDemoProjectsResponse(orgUid, orgName)).pipe(delay(DEMO_LATENCY_MS)); + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 99c187764..164afcc54 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -52,6 +52,7 @@ export * from './transaction.constants'; export * from './rewards.constants'; export * from './regex.constants'; export * from './org-lens.constants'; +export * from './org-lens-projects.constants'; export * from './org-memberships.constants'; export * from './feature-flags.constants'; export * from './org-selector.constants'; diff --git a/packages/shared/src/constants/org-lens-projects.constants.ts b/packages/shared/src/constants/org-lens-projects.constants.ts new file mode 100644 index 000000000..8f3c15b7b --- /dev/null +++ b/packages/shared/src/constants/org-lens-projects.constants.ts @@ -0,0 +1,108 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { + FilterPillOption, + HealthScore, + InfluenceBand, + InfluenceSummaryMode, + InfluenceTrendDirection, + OrgProjectsSortField, + OrgProjectsWorkspaceId, + SortDirection, + TagSeverity, +} from '../interfaces'; +import { lfxColors } from './colors.constants'; + +/** Workspace presets shown in the Workspace select (`?workspace=`). `most-active` is the default. */ +export const ORG_PROJECTS_WORKSPACE_OPTIONS: ReadonlyArray<{ label: string; value: OrgProjectsWorkspaceId }> = [ + { label: 'Most Active Projects', value: 'most-active' }, + { label: 'All Projects', value: 'all-projects' }, + { label: 'Most Influential', value: 'most-influential' }, + { label: 'Where We Lead', value: 'where-we-lead' }, +]; + +export const DEFAULT_ORG_PROJECTS_WORKSPACE_ID: OrgProjectsWorkspaceId = 'most-active'; + +export const VALID_ORG_PROJECTS_WORKSPACE_IDS = new Set(['most-active', 'all-projects', 'most-influential', 'where-we-lead']); + +/** Influence Summary pill tabs (`?influenceTab=`). `influential` is the default. */ +export const ORG_PROJECTS_INFLUENCE_TABS: ReadonlyArray = [ + { id: 'influential', label: 'Most Influential' }, + { id: 'gains', label: 'Most Gains in Influence' }, + { id: 'decreases', label: 'Most Decreases in Influence' }, +]; + +export const DEFAULT_INFLUENCE_SUMMARY_MODE: InfluenceSummaryMode = 'influential'; + +export const VALID_INFLUENCE_SUMMARY_MODES = new Set(['influential', 'gains', 'decreases']); + +/** Number of cards rendered per Influence Summary mode. */ +export const INFLUENCE_SUMMARY_CARD_COUNT = 3; + +/** Display labels for influence bands. */ +export const INFLUENCE_BAND_LABELS: Record = { + leading: 'Leading', + contributing: 'Contributing', + participating: 'Participating', + 'non-lf': 'Non-LF', +}; + +/** Tag/badge severity per influence band (drives band-chip color). */ +export const INFLUENCE_BAND_SEVERITY: Record = { + leading: 'success', + contributing: 'info', + participating: 'warn', + 'non-lf': 'secondary', +}; + +/** Sparkline / delta color per trend direction (brand scale values; never hard-coded hex). */ +export const INFLUENCE_TREND_COLOR: Record = { + up: lfxColors.emerald[500], + down: lfxColors.red[500], + flat: lfxColors.gray[400], +}; + +/** Sort rank for influence bands (strongest highest), used for the table tie-break. */ +export const INFLUENCE_BAND_RANK: Record = { + leading: 3, + contributing: 2, + participating: 1, + 'non-lf': 0, +}; + +/** Display labels for health scores. */ +export const HEALTH_SCORE_LABELS: Record = { + excellent: 'Excellent', + healthy: 'Healthy', + 'at-risk': 'At Risk', +}; + +/** Tag/badge severity per health score (drives health-badge color). */ +export const HEALTH_SCORE_SEVERITY: Record = { + excellent: 'success', + healthy: 'info', + 'at-risk': 'danger', +}; + +/** Projects-table page sizes; 25 is the default. */ +export const ORG_PROJECTS_PAGE_SIZE_OPTIONS: readonly number[] = [10, 25, 50]; + +export const DEFAULT_ORG_PROJECTS_PAGE_SIZE = 25; + +/** Default Projects-table sort: Influence Trend, descending. */ +export const DEFAULT_ORG_PROJECTS_SORT_FIELD: OrgProjectsSortField = 'influenceTrend'; + +export const DEFAULT_ORG_PROJECTS_SORT_DIR: SortDirection = 'desc'; + +export const VALID_ORG_PROJECTS_SORT_FIELDS = new Set([ + 'name', + 'foundation', + 'health', + 'technicalInfluence', + 'ecosystemInfluence', + 'influenceTrend', +]); + +/** Maximum avatars shown in a table avatar stack before collapsing into a `+N` chip. */ +export const ORG_PROJECTS_AVATAR_STACK_LIMIT = 4; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 3b81d2526..e5b549af3 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -75,6 +75,9 @@ export * from './account.interface'; // Org Lens (per-account TLF membership tier + cdev org mapping) interfaces export * from './org-lens.interface'; +// Org Lens Projects page (influence + health) interfaces +export * from './org-lens-projects.interface'; + // Mailing list interfaces export * from './mailing-list.interface'; diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts new file mode 100644 index 000000000..513e5f838 --- /dev/null +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -0,0 +1,124 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Org Lens — Projects page contracts (LFXV2-1883 / LFXV2-1884). + * + * These are the real API contracts the Projects page renders against. The current + * implementation is fed by a demo-data fixture through `OrgLensProjectsService`; the + * live Snowflake / LFX Insights integration (a separate story) will populate the same + * shapes without any component changes. + */ + +/** Influence band per the markup-mu methodology (Boysel et al.). Declared strongest → weakest. */ +export type InfluenceBand = 'leading' | 'contributing' | 'participating' | 'non-lf'; + +/** CHAOSS-derived project health classification (via LFX Insights). */ +export type HealthScore = 'excellent' | 'healthy' | 'at-risk'; + +/** Direction of a one-year influence trend, used for color-coding the sparkline + delta. */ +export type InfluenceTrendDirection = 'up' | 'down' | 'flat'; + +/** A person (employee) associated with a project in a maintainer/contributor/participant role. */ +export interface OrgLensProjectPerson { + /** Stable identifier for the person. */ + id: string; + /** Display name shown on avatar hover. */ + name: string; + /** Avatar image URL; empty string falls back to initials. */ + avatarUrl: string; +} + +/** Foundation a project belongs to (logo + name pill). */ +export interface OrgLensProjectFoundation { + /** URL-safe foundation slug, used by the `?foundation=` filter. */ + slug: string; + /** Display name (e.g. "CNCF"). */ + name: string; + /** Foundation logo URL; empty string falls back to a generic glyph. */ + logoUrl: string; +} + +/** Rolling one-year influence trend: a sparkline series plus the headline percent delta. */ +export interface InfluenceTrend { + /** Percent change of the combined influence score, rolling 365 vs prior 365. */ + deltaPct: number; + /** Direction bucket derived from `deltaPct` (drives green/red/neutral styling). */ + direction: InfluenceTrendDirection; + /** Ordered score samples (oldest → newest) for the sparkline. */ + series: number[]; +} + +/** The single largest signal contributing to a project's influence delta. */ +export interface ChangeDriver { + /** Human-readable driver label (e.g. "+3 maintainers", "-22% commits"). */ + label: string; + /** Whether the driver pushed influence up or down. */ + direction: InfluenceTrendDirection; +} + +/** A single project row in the Org Lens Projects table. */ +export interface OrgLensProject { + /** URL-safe project slug (links to Project Detail). */ + slug: string; + /** Display name. */ + name: string; + /** Project logo URL; empty string falls back to initials. */ + logoUrl: string; + /** Owning foundation. */ + foundation: OrgLensProjectFoundation; + /** CHAOSS health classification. */ + health: HealthScore; + /** Technical influence band. */ + technicalInfluence: InfluenceBand; + /** Ecosystem influence band. */ + ecosystemInfluence: InfluenceBand; + /** Current combined influence score (markup-mu). */ + influenceScore: number; + /** Combined influence score at T-365; `0` means no baseline (excluded from Gains). */ + priorYearScore: number; + /** One-year influence trend. */ + trend: InfluenceTrend; + /** Employees with maintainer roles on the project. */ + maintainers: OrgLensProjectPerson[]; + /** Employees with contributor roles on the project. */ + contributors: OrgLensProjectPerson[]; + /** Employees with participant roles on the project. */ + participants: OrgLensProjectPerson[]; + /** Commit count over the trailing 12 months. */ + commits1y: number; + /** Largest single driver of the influence delta (used by Influence Summary cards). */ + changeDriver: ChangeDriver; +} + +/** Top-level response for the Org Lens Projects page. */ +export interface OrgLensProjectsResponse { + /** URL-safe org slug, used in the CSV export filename. */ + orgSlug: string; + /** Display name of the organization. */ + orgName: string; + /** ISO timestamp of the LFX Insights data build, rendered as the freshness label. */ + dataUpdatedAt: string; + /** All projects the organization is associated with. */ + projects: OrgLensProject[]; +} + +/** Workspace preset / saved-filter identifier (`?workspace=`). */ +export type OrgProjectsWorkspaceId = 'most-active' | 'all-projects' | 'most-influential' | 'where-we-lead'; + +/** Sortable Projects-table column keys (`?sort=`). */ +export type OrgProjectsSortField = 'name' | 'foundation' | 'health' | 'technicalInfluence' | 'ecosystemInfluence' | 'influenceTrend'; + +/** Sort direction (`?dir=`). */ +export type SortDirection = 'asc' | 'desc'; + +/** Influence Summary pill-tab mode (`?influenceTab=`). */ +export type InfluenceSummaryMode = 'influential' | 'gains' | 'decreases'; + +/** A single Influence Summary card, derived from an `OrgLensProject` for the active mode. */ +export interface InfluenceSummaryCard { + /** The project this card represents. */ + project: OrgLensProject; + /** Mode-specific primary metric line (e.g. "Influence score: 87.3 (Leading)", "+14.2 points (1y)"). */ + primaryMetric: string; +} diff --git a/packages/shared/src/utils/file.utils.ts b/packages/shared/src/utils/file.utils.ts index e8c3c274b..795648152 100644 --- a/packages/shared/src/utils/file.utils.ts +++ b/packages/shared/src/utils/file.utils.ts @@ -282,3 +282,46 @@ export function sanitizeFilename(filename: string, maxLength: number = 255): str return sanitized; } + +/** + * Escape a single CSV cell per RFC 4180: wrap in quotes when the value contains a + * comma, quote, or newline, and double any embedded quotes. + * @param value - Raw cell value + * @returns CSV-safe cell string + */ +function escapeCsvCell(value: string | number): string { + const str = String(value ?? ''); + return /[",\r\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str; +} + +/** + * Build an RFC 4180 CSV string from a matrix of rows (first row is typically the header). + * @param rows - Array of rows, each an array of cell values + * @returns CSV text with CRLF line endings + */ +export function rowsToCsv(rows: ReadonlyArray>): string { + return rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n'); +} + +/** + * Trigger a client-side CSV file download in the browser. No-op during SSR (no `document`). + * Prepends a UTF-8 BOM so Excel detects the encoding. + * @param filename - Download filename (e.g. "report.csv") + * @param rows - Array of rows, each an array of cell values + */ +export function downloadCsv(filename: string, rows: ReadonlyArray>): void { + if (typeof document === 'undefined') { + return; + } + + const blob = new Blob(['\ufeff' + rowsToCsv(rows)], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} From 2d8d4cf03bc5c307a980462740e77907c2ea7c0c Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 10 Jun 2026 15:06:22 -0600 Subject: [PATCH 02/12] feat(org-lens): refine projects page and add workspace dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the Org Lens Projects page (LFXV2-1883) on top of the initial scaffold: removes the Influence Summary section, replaces influence band chips with signal-strength bar icons (lighter-tint unfilled bars, Non-LF slash), adds GitHub-avatar project logos, a health-detail hover popover, wider column tooltips, blue active-sort indicators, and a shared-workspaces dropdown with add / rename / delete. Default sort is contributors then participants; columns are Contributors + Participants (Maintainers/Foundation removed). Still demo-company data only — real Snowflake/Insights integration is a separate story. LFXV2-1883 Signed-off-by: daniel qualls --- .../influence-summary.component.html | 106 ------ .../influence-summary.component.ts | 140 ------- .../org-projects/org-projects.component.html | 354 +++++++++++++----- .../org-projects/org-projects.component.ts | 265 +++++++++---- .../services/org-lens-projects.demo-data.ts | 118 +++++- apps/lfx-one/src/styles.scss | 12 + apps/lfx-one/tailwind.config.js | 12 + .../constants/org-lens-projects.constants.ts | 77 ++-- .../interfaces/org-lens-projects.interface.ts | 40 +- 9 files changed, 643 insertions(+), 481 deletions(-) delete mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html delete mode 100644 apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html deleted file mode 100644 index c3a385238..000000000 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.html +++ /dev/null @@ -1,106 +0,0 @@ - - - -
- -
-

Influence Summary

-

Top 3 projects by influence, gains, and decreases over the past 12 months. Methodology: markup-mu (Boysel et al.).

-
- - - - - - - - @if (error()) { -
- -

Couldn't load the influence summary.

- -
- } @else if (loading()) { -
- @for (slot of skeletonCards; track $index) { -
-
-
-
-
- } -
- } @else if (hasNoQualifying()) { -
- - @if (activeMode() === 'influential') { -

Add more projects to your workspace to see your most influential projects here.

- } @else { -

Not enough 12-month history to compute gains/decreases yet. Check back next month.

- } -
- } @else { -
- @for (card of cards(); track card.project.slug) { -
- -
- - -
- - -
- - -
-

{{ card.primaryMetric }}

- @if (activeMode() === 'influential') { -
- - -
- } @else { -
- - -
- } -
- - -
-
- {{ card.project.maintainers.length }} - Maintainers -
-
- {{ card.project.contributors.length }} - Contributors -
-
- {{ card.project.commits1y | number }} - 1y Commits -
-
- - - -
- } - - - @if (activeMode() === 'influential') { - @for (slot of fillerSlots(); track $index) { -
- -

Add more projects to your workspace to see more here.

-
- } - } -
- } -
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts deleted file mode 100644 index 1195339f2..000000000 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/components/influence-summary/influence-summary.component.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -// Generated with [Claude Code](https://claude.ai/code) - -import { DecimalPipe } from '@angular/common'; -import { Component, computed, inject, input, output, Signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; -import { - DEFAULT_INFLUENCE_SUMMARY_MODE, - HEALTH_SCORE_LABELS, - HEALTH_SCORE_SEVERITY, - INFLUENCE_BAND_LABELS, - INFLUENCE_BAND_SEVERITY, - INFLUENCE_SUMMARY_CARD_COUNT, - INFLUENCE_TREND_COLOR, - ORG_PROJECTS_INFLUENCE_TABS, - VALID_INFLUENCE_SUMMARY_MODES, -} from '@lfx-one/shared/constants'; -import type { HealthScore, InfluenceBand, InfluenceSummaryCard, InfluenceSummaryMode, OrgLensProject, TagSeverity } from '@lfx-one/shared/interfaces'; - -import { AvatarComponent } from '@components/avatar/avatar.component'; -import { ButtonComponent } from '@components/button/button.component'; -import { CardTabsBarComponent } from '@components/card-tabs-bar/card-tabs-bar.component'; -import { ChartComponent } from '@components/chart/chart.component'; -import { TagComponent } from '@components/tag/tag.component'; - -@Component({ - selector: 'lfx-influence-summary', - imports: [AvatarComponent, ButtonComponent, CardTabsBarComponent, ChartComponent, DecimalPipe, TagComponent], - templateUrl: './influence-summary.component.html', -}) -export class InfluenceSummaryComponent { - // Inputs / outputs - public readonly projects = input([]); - public readonly loading = input(false); - public readonly error = input(false); - public readonly retryRequested = output(); - - // Private injections - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - - // Configuration - protected readonly tabs = ORG_PROJECTS_INFLUENCE_TABS.map((tab) => ({ id: tab.id, label: tab.label })); - protected readonly skeletonCards = Array.from({ length: INFLUENCE_SUMMARY_CARD_COUNT }); - // Minimal Chart.js line config for the card sparkline (no axes, points, legend, or tooltip). - protected readonly sparklineOptions = { - responsive: true, - maintainAspectRatio: false, - elements: { point: { radius: 0 }, line: { borderWidth: 2, tension: 0.4 } }, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { x: { display: false }, y: { display: false } }, - }; - - // Computed / toSignal - private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap }); - protected readonly activeMode: Signal = computed(() => { - const raw = this.queryParamMap().get('influenceTab'); - return raw && VALID_INFLUENCE_SUMMARY_MODES.has(raw as InfluenceSummaryMode) ? (raw as InfluenceSummaryMode) : DEFAULT_INFLUENCE_SUMMARY_MODE; - }); - protected readonly cards: Signal = computed(() => this.buildCards(this.projects(), this.activeMode())); - /** True when the active mode has no qualifying projects at all (drives insufficient-history copy). */ - protected readonly hasNoQualifying = computed(() => !this.loading() && !this.error() && this.cards().length === 0); - /** Empty slots to backfill the grid when fewer than 3 cards qualify. */ - protected readonly fillerSlots = computed(() => Array.from({ length: Math.max(0, INFLUENCE_SUMMARY_CARD_COUNT - this.cards().length) })); - - // Protected methods - protected switchMode(modeId: string): void { - if (!VALID_INFLUENCE_SUMMARY_MODES.has(modeId as InfluenceSummaryMode) || modeId === this.activeMode()) { - return; - } - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { influenceTab: modeId === DEFAULT_INFLUENCE_SUMMARY_MODE ? null : modeId }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - } - - protected openDetail(project: OrgLensProject): void { - // Project Detail sub-page is delivered in LFXV2-1885; navigation target wired there. - void this.router.navigate([], { relativeTo: this.route, queryParams: { project: project.slug }, queryParamsHandling: 'merge' }); - } - - protected bandLabel(band: InfluenceBand): string { - return INFLUENCE_BAND_LABELS[band]; - } - protected bandSeverity(band: InfluenceBand): TagSeverity { - return INFLUENCE_BAND_SEVERITY[band]; - } - protected healthLabel(health: HealthScore): string { - return HEALTH_SCORE_LABELS[health]; - } - protected healthSeverity(health: HealthScore): TagSeverity { - return HEALTH_SCORE_SEVERITY[health]; - } - protected driverSeverity(): TagSeverity { - return this.activeMode() === 'gains' ? 'success' : 'danger'; - } - protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { - return { - labels: project.trend.series.map((_, i) => String(i)), - datasets: [{ data: project.trend.series, borderColor: INFLUENCE_TREND_COLOR[project.trend.direction], fill: false }], - }; - } - - // Private helpers - private buildCards(projects: OrgLensProject[], mode: InfluenceSummaryMode): InfluenceSummaryCard[] { - if (mode === 'gains') { - return projects - .filter((p) => p.priorYearScore !== 0) - .sort((a, b) => this.delta(b) - this.delta(a) || a.name.localeCompare(b.name)) - .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) - .map((project) => ({ project, primaryMetric: `${this.signed(this.delta(project))} points (1y)` })); - } - if (mode === 'decreases') { - return projects - .filter((p) => p.influenceScore !== 0) - .sort((a, b) => this.delta(a) - this.delta(b) || a.name.localeCompare(b.name)) - .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) - .map((project) => ({ project, primaryMetric: `${this.signed(this.delta(project))} points (1y)` })); - } - // Most Influential: current score desc, tie-break 1y delta desc, then name asc. - return projects - .filter((p) => p.influenceScore > 0) - .sort((a, b) => b.influenceScore - a.influenceScore || b.trend.deltaPct - a.trend.deltaPct || a.name.localeCompare(b.name)) - .slice(0, INFLUENCE_SUMMARY_CARD_COUNT) - .map((project) => ({ project, primaryMetric: `Influence score: ${project.influenceScore} (${INFLUENCE_BAND_LABELS[project.technicalInfluence]})` })); - } - - private delta(project: OrgLensProject): number { - return Math.round((project.influenceScore - project.priorYearScore) * 10) / 10; - } - - private signed(value: number): string { - return value > 0 ? `+${value}` : String(value); - } -} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html index 2bffbfed0..005396627 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -4,41 +4,33 @@
-
-

- @if (companyName()) { - Projects — {{ companyName() }} - } @else { - Projects - } -

- @if (freshnessLabel()) { - {{ freshnessLabel() }} +

+ @if (companyName()) { + Projects — {{ companyName() }} + } @else { + Projects } -

+

The open source projects your organization influences, contributes to, and depends on.

- - -
-
- +
+
+ data-testid="org-projects-add-project" />
@@ -115,50 +107,104 @@

- - - - - - + + + - - - - + + + - - - Maintainers - Contributors - Participants - Actions + Actions @@ -175,34 +221,75 @@

- - - - - - - + -
- - - {{ project.trend.deltaPct > 0 ? '+' : '' }}{{ project.trend.deltaPct }}% - -
+ + + + + + + + + {{ bandLabel(project.technicalInfluence) }} + + + + + + + {{ bandLabel(project.ecosystemInfluence) }} + + + + + + + + + + + {{ project.contributors.length }} - - - - - + {{ project.participants.length }} + + + @@ -218,25 +305,110 @@

- - - @if (people.length) { -
-
- @for (person of stackVisible(people); track person.id) { - - - - } - @if (stackOverflow(people) > 0) { - - +{{ stackOverflow(people) }} - + + + @if (activeHealthProject(); as project) { +
+
+ Health score + + + LFX Insights + +
+ +

{{ project.description }}

+
+ @for (metric of project.healthMetrics; track metric.label) { +
+
+ {{ metric.label }} + {{ metric.value }} +
+
+
+
+
}
- {{ people.length }}
- } @else { - } - +
+ + + +
+
Shared Workspaces
+ @for (ws of workspaces(); track ws.id) { +
+ + +
+ } +
+ +
+
+ + + +
+
+ + +
+
+ @if (editingWorkspace() && workspaces().length > 1) { + + } @else { + + } +
+ + +
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index 59fa62d83..497f57c5f 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -3,7 +3,6 @@ // Generated with [Claude Code](https://claude.ai/code) -import { NgTemplateOutlet } from '@angular/common'; import { Component, computed, inject, signal, Signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup } from '@angular/forms'; @@ -13,32 +12,33 @@ import { DEFAULT_ORG_PROJECTS_SORT_DIR, DEFAULT_ORG_PROJECTS_SORT_FIELD, DEFAULT_ORG_PROJECTS_WORKSPACE_ID, + DEFAULT_ORG_PROJECTS_WORKSPACES, HEALTH_SCORE_LABELS, HEALTH_SCORE_SEVERITY, + INFLUENCE_BAND_BAR_FILL_CLASS, + INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT, INFLUENCE_BAND_LABELS, INFLUENCE_BAND_RANK, - INFLUENCE_BAND_SEVERITY, INFLUENCE_TREND_COLOR, - ORG_PROJECTS_AVATAR_STACK_LIMIT, ORG_PROJECTS_PAGE_SIZE_OPTIONS, - ORG_PROJECTS_WORKSPACE_OPTIONS, VALID_ORG_PROJECTS_SORT_FIELDS, - VALID_ORG_PROJECTS_WORKSPACE_IDS, } from '@lfx-one/shared/constants'; import type { HealthScore, InfluenceBand, - InfluenceTrendDirection, OrgLensProject, - OrgLensProjectPerson, OrgLensProjectsResponse, OrgProjectsSortField, + OrgProjectsWorkspace, OrgProjectsWorkspaceId, SortDirection, TagSeverity, } from '@lfx-one/shared/interfaces'; -import { downloadCsv, formatRelativeTime } from '@lfx-one/shared/utils'; +import { downloadCsv } from '@lfx-one/shared/utils'; import { MenuItem } from 'primeng/api'; +import { DialogModule } from 'primeng/dialog'; +import { Popover, PopoverModule } from 'primeng/popover'; +import { TooltipModule } from 'primeng/tooltip'; import { catchError, finalize, of, switchMap } from 'rxjs'; import { AvatarComponent } from '@components/avatar/avatar.component'; @@ -46,6 +46,7 @@ import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.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 { MenuComponent } from '@components/menu/menu.component'; import { MultiSelectComponent } from '@components/multi-select/multi-select.component'; import { SelectComponent } from '@components/select/select.component'; @@ -54,8 +55,6 @@ import { TagComponent } from '@components/tag/tag.component'; import { AccountContextService } from '@shared/services/account-context.service'; import { OrgLensProjectsService } from '@shared/services/org-lens-projects.service'; -import { InfluenceSummaryComponent } from './components/influence-summary/influence-summary.component'; - const ALL_FOUNDATIONS = 'all'; @Component({ @@ -65,14 +64,16 @@ const ALL_FOUNDATIONS = 'all'; ButtonComponent, CardComponent, ChartComponent, + DialogModule, EmptyStateComponent, - InfluenceSummaryComponent, + InputTextComponent, MenuComponent, MultiSelectComponent, - NgTemplateOutlet, + PopoverModule, SelectComponent, TableComponent, TagComponent, + TooltipModule, ], templateUrl: './org-projects.component.html', }) @@ -82,11 +83,11 @@ export class OrgProjectsComponent { private readonly router = inject(Router); private readonly accountContext = inject(AccountContextService); private readonly projectsService = inject(OrgLensProjectsService); + /** Pending hide timer for the health popover (lets the cursor cross into the popover). */ + private healthHideTimer: ReturnType | null = null; // Configuration - protected readonly workspaceOptions = [...ORG_PROJECTS_WORKSPACE_OPTIONS]; protected readonly pageSizeOptions = [...ORG_PROJECTS_PAGE_SIZE_OPTIONS]; - protected readonly avatarStackLimit = ORG_PROJECTS_AVATAR_STACK_LIMIT; // Minimal Chart.js line config for the Influence Trend sparkline (no axes, points, legend, or tooltip). protected readonly sparklineOptions = { responsive: true, @@ -98,10 +99,13 @@ export class OrgProjectsComponent { // Forms protected readonly filterForm = new FormGroup({ - workspace: new FormControl(this.readWorkspaceFromUrl(), { nonNullable: true }), foundation: new FormControl(this.route.snapshot.queryParamMap.get('foundation') ?? ALL_FOUNDATIONS, { nonNullable: true }), employees: new FormControl(this.readEmployeesFromUrl(), { nonNullable: true }), }); + /** Name field for the add / rename workspace dialog. */ + protected readonly workspaceForm = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + }); // Writable Signals protected readonly loading = signal(false); @@ -109,6 +113,11 @@ export class OrgProjectsComponent { /** Per-user workspace pin/hide state (client-only; never mutates the foundation catalog). */ protected readonly pinnedSlugs = signal>(new Set()); protected readonly hiddenSlugs = signal>(new Set()); + /** Shared workspaces (seeded presets + user-created); editable via the workspace dropdown. */ + protected readonly workspaces = signal([...DEFAULT_ORG_PROJECTS_WORKSPACES]); + /** Workspace being renamed/deleted in the settings dialog; `null` while the dialog adds a new one. */ + protected readonly editingWorkspace = signal(null); + protected readonly workspaceDialogOpen = signal(false); /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ private readonly reload = signal(0); /** Action menu items rebuilt per row when the kebab is opened. */ @@ -120,10 +129,8 @@ export class OrgProjectsComponent { private readonly response: Signal = this.initResponse(); protected readonly companyName = computed(() => this.accountContext.selectedAccount()?.accountName ?? ''); - protected readonly freshnessLabel = computed(() => { - const updatedAt = this.response()?.dataUpdatedAt; - return updatedAt ? `Data updated ${formatRelativeTime(new Date(updatedAt))}` : ''; - }); + /** Project whose health detail is shown in the shared hover popover. */ + protected readonly activeHealthProject = signal(null); protected readonly sortField = computed(() => { const raw = this.queryParamMap().get('sort'); @@ -139,7 +146,19 @@ export class OrgProjectsComponent { return (page - 1) * this.pageSize(); }); - protected readonly workspaceId = computed(() => this.formValue().workspace ?? DEFAULT_ORG_PROJECTS_WORKSPACE_ID); + // Active workspace comes from the URL (`?workspace=`), validated against the current workspace list. + protected readonly selectedWorkspaceId = computed(() => { + const list = this.workspaces(); + const raw = this.queryParamMap().get('workspace'); + if (raw && list.some((w) => w.id === raw)) { + return raw; + } + if (list.some((w) => w.id === DEFAULT_ORG_PROJECTS_WORKSPACE_ID)) { + return DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + } + return list[0]?.id ?? DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + }); + protected readonly selectedWorkspaceName = computed(() => this.workspaces().find((w) => w.id === this.selectedWorkspaceId())?.name ?? ''); protected readonly foundationOptions = this.initFoundationOptions(); protected readonly employeeOptions = this.initEmployeeOptions(); @@ -150,12 +169,11 @@ export class OrgProjectsComponent { protected readonly totalRecords = computed(() => this.sortedProjects().length); public constructor() { - // Filter changes (workspace / foundation / employees) write through to the URL and reset to page 1. + // Filter changes (foundation / employees) write through to the URL and reset to page 1. this.filterForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { void this.router.navigate([], { relativeTo: this.route, queryParams: { - workspace: value.workspace === DEFAULT_ORG_PROJECTS_WORKSPACE_ID ? null : value.workspace, foundation: value.foundation === ALL_FOUNDATIONS ? null : value.foundation, employees: value.employees && value.employees.length ? value.employees.join(',') : null, page: null, @@ -176,7 +194,11 @@ export class OrgProjectsComponent { const nextDir: SortDirection = this.sortField() === field && this.sortDir() === 'desc' ? 'asc' : 'desc'; void this.router.navigate([], { relativeTo: this.route, - queryParams: { sort: field === DEFAULT_ORG_PROJECTS_SORT_FIELD ? null : field, dir: nextDir === DEFAULT_ORG_PROJECTS_SORT_DIR ? null : nextDir, page: null }, + queryParams: { + sort: field === DEFAULT_ORG_PROJECTS_SORT_FIELD ? null : field, + dir: nextDir === DEFAULT_ORG_PROJECTS_SORT_DIR ? null : nextDir, + page: null, + }, queryParamsHandling: 'merge', replaceUrl: true, }); @@ -194,20 +216,73 @@ export class OrgProjectsComponent { } protected resetFilters(): void { - this.filterForm.reset({ workspace: DEFAULT_ORG_PROJECTS_WORKSPACE_ID, foundation: ALL_FOUNDATIONS, employees: [] }); + this.filterForm.reset({ foundation: ALL_FOUNDATIONS, employees: [] }); + this.selectWorkspace(DEFAULT_ORG_PROJECTS_WORKSPACE_ID); } + // Active sort column shows a solid blue arrow (LFX self-serve pattern); inactive columns a faint grey double-arrow. protected sortIcon(field: OrgProjectsSortField): string { if (this.sortField() !== field) { - return 'fa-light fa-sort'; + return 'fa-light fa-sort text-gray-300'; } - return this.sortDir() === 'asc' ? 'fa-light fa-sort-up' : 'fa-light fa-sort-down'; + return this.sortDir() === 'asc' ? 'fa-solid fa-sort-up text-blue-500' : 'fa-solid fa-sort-down text-blue-500'; } protected openFindProject(): void { // +Find project opens an add-project modal whose internals ship in a separate ticket. } + protected selectWorkspace(id: OrgProjectsWorkspaceId): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { workspace: id === DEFAULT_ORG_PROJECTS_WORKSPACE_ID ? null : id, page: null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected openAddWorkspace(): void { + this.editingWorkspace.set(null); + this.workspaceForm.setValue({ name: '' }); + this.workspaceDialogOpen.set(true); + } + + protected openWorkspaceSettings(workspace: OrgProjectsWorkspace): void { + this.editingWorkspace.set(workspace); + this.workspaceForm.setValue({ name: workspace.name }); + this.workspaceDialogOpen.set(true); + } + + protected saveWorkspace(): void { + const name = this.workspaceForm.getRawValue().name.trim(); + if (!name) { + return; + } + const editing = this.editingWorkspace(); + if (editing) { + this.workspaces.update((list) => list.map((w) => (w.id === editing.id ? { ...w, name } : w))); + } else { + const id = this.uniqueWorkspaceId(name); + this.workspaces.update((list) => [...list, { id, name }]); + this.selectWorkspace(id); + } + this.workspaceDialogOpen.set(false); + } + + protected deleteWorkspace(): void { + const editing = this.editingWorkspace(); + // Always keep at least one workspace so the company never ends up with none. + if (!editing || this.workspaces().length <= 1) { + return; + } + const remaining = this.workspaces().filter((w) => w.id !== editing.id); + this.workspaces.set(remaining); + if (this.selectedWorkspaceId() === editing.id) { + this.selectWorkspace(remaining[0].id); + } + this.workspaceDialogOpen.set(false); + } + protected openRowMenu(menu: MenuComponent, project: OrgLensProject, event: Event): void { this.rowMenuItems = this.buildRowMenu(project); menu.toggle(event); @@ -223,25 +298,13 @@ export class OrgProjectsComponent { if (!rows.length) { return; } - const header = [ - 'Project', - 'Foundation', - 'Health Score', - 'Technical Influence', - 'Ecosystem Influence', - 'Influence Trend (1y) %', - 'Our Maintainers', - 'Our Contributors', - 'Our Participants', - ]; + const header = ['Project', 'Health Score', 'Technical Influence', 'Ecosystem Influence', 'Influence Trend (1y) %', 'Our Contributors', 'Our Participants']; const body = rows.map((p) => [ p.name, - p.foundation.name, HEALTH_SCORE_LABELS[p.health], INFLUENCE_BAND_LABELS[p.technicalInfluence], INFLUENCE_BAND_LABELS[p.ecosystemInfluence], p.trend.deltaPct, - p.maintainers.length, p.contributors.length, p.participants.length, ]); @@ -254,8 +317,42 @@ export class OrgProjectsComponent { protected bandLabel(band: InfluenceBand): string { return INFLUENCE_BAND_LABELS[band]; } - protected bandSeverity(band: InfluenceBand): TagSeverity { - return INFLUENCE_BAND_SEVERITY[band]; + // Signal-strength bars for an influence band: filled count = rank (Leading 4 → Silent 1 → Non-LF 0), + // colored by band; remaining bars faded. Non-LF (0 filled) gets a diagonal slash from the template. + // Geometry mirrors the LFX Insights signal-bar icon: 4 evenly spaced, ascending, rounded bars in a 16×16 box. + protected bandBars(band: InfluenceBand): { x: number; y: number; w: number; h: number; colorClass: string }[] { + const heights = [5, 8.3, 11.6, 15]; + const barWidth = 2.6; + const gap = 1.8; + const filled = INFLUENCE_BAND_RANK[band]; + return heights.map((h, i) => ({ + x: i * (barWidth + gap), + y: 16 - h, + w: barWidth, + h, + // Filled bars use the band color; unfilled use a lighter tint of the same color (org dashboard design). + colorClass: i < filled ? INFLUENCE_BAND_BAR_FILL_CLASS[band] : INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT[band], + })); + } + // Explanatory hover for the Technical / Ecosystem influence column headers. + protected influenceColumnTooltip(): string { + return `
  • Technical influence examines code activities (commits, PRs) while ecosystem influence examines non-code collaboration activities (documentation, committees, meetings, events).
  • Comparing our company's share of these activities to the project total indicates greater influence in the project.
`; + } + protected openHealth(event: Event, project: OrgLensProject, popover: Popover): void { + this.cancelHealthHide(); + this.activeHealthProject.set(project); + popover.show(event, event.currentTarget as HTMLElement); + } + // Delay hide so the cursor can travel from the cell into the popover (keeps the LFX Insights link clickable). + protected scheduleHealthHide(popover: Popover): void { + this.cancelHealthHide(); + this.healthHideTimer = setTimeout(() => popover.hide(), 200); + } + protected cancelHealthHide(): void { + if (this.healthHideTimer !== null) { + clearTimeout(this.healthHideTimer); + this.healthHideTimer = null; + } } protected healthLabel(health: HealthScore): string { return HEALTH_SCORE_LABELS[health]; @@ -263,20 +360,23 @@ export class OrgProjectsComponent { protected healthSeverity(health: HealthScore): TagSeverity { return HEALTH_SCORE_SEVERITY[health]; } - protected trendClass(direction: InfluenceTrendDirection): string { - if (direction === 'up') { - return 'text-emerald-600'; - } - if (direction === 'down') { - return 'text-red-600'; - } - return 'text-gray-500'; + // Hover tooltip for the Influence Trend sparkline: combined / technical / ecosystem 1y deltas. + protected trendTooltip(project: OrgLensProject): string { + const t = project.trend; + return `
${this.trendTooltipRow('Combined influence', t.deltaPct)}${this.trendTooltipRow('Technical influence', t.technicalDeltaPct)}${this.trendTooltipRow('Ecosystem influence', t.ecosystemDeltaPct)}
`; } - protected stackVisible(people: OrgLensProjectPerson[]): OrgLensProjectPerson[] { - return people.slice(0, this.avatarStackLimit); + protected trendTooltipRow(label: string, value: number): string { + const sign = value > 0 ? '+' : ''; + return `
${label}${sign}${value}%
`; } - protected stackOverflow(people: OrgLensProjectPerson[]): number { - return Math.max(0, people.length - this.avatarStackLimit); + protected pctColorClass(value: number): string { + if (value > 1) { + return 'text-emerald-300'; + } + if (value < -1) { + return 'text-red-300'; + } + return 'text-gray-300'; } protected isPinned(slug: string): boolean { return this.pinnedSlugs().has(slug); @@ -294,12 +394,9 @@ export class OrgProjectsComponent { return toSignal( account$.pipe( switchMap(({ account }) => { - const uid = account?.uid; - if (!uid) { - this.loading.set(false); - this.error.set(false); - return of(null); - } + // Demo data is not tied to a real org, so it renders even before an org is selected + // (local dev / no impersonation). The real integration will key off `account.uid`. + const uid = account?.uid ?? 'demo-org'; this.loading.set(true); this.error.set(false); return this.projectsService.getProjects(uid, account?.accountName ?? '').pipe( @@ -343,7 +440,7 @@ export class OrgProjectsComponent { private initFilteredProjects(): Signal { return computed(() => { const all = this.response()?.projects ?? []; - const workspace = this.workspaceId(); + const workspace = this.selectedWorkspaceId(); const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; const hidden = this.hiddenSlugs(); return all @@ -377,17 +474,15 @@ export class OrgProjectsComponent { if (directed !== 0) { return directed; } - // Tie-break: Technical Influence band desc, then project name asc. - const bandTie = INFLUENCE_BAND_RANK[b.technicalInfluence] - INFLUENCE_BAND_RANK[a.technicalInfluence]; - return bandTie !== 0 ? bandTie : a.name.localeCompare(b.name); + // Tie-break: participant count desc, then project name asc. + const participantTie = b.participants.length - a.participants.length; + return participantTie !== 0 ? participantTie : a.name.localeCompare(b.name); } private compareByField(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField): number { switch (field) { case 'name': return a.name.localeCompare(b.name); - case 'foundation': - return a.foundation.name.localeCompare(b.foundation.name); case 'health': return this.healthRank(a.health) - this.healthRank(b.health); case 'technicalInfluence': @@ -396,6 +491,10 @@ export class OrgProjectsComponent { return INFLUENCE_BAND_RANK[a.ecosystemInfluence] - INFLUENCE_BAND_RANK[b.ecosystemInfluence]; case 'influenceTrend': return a.trend.deltaPct - b.trend.deltaPct; + case 'contributors': + return a.contributors.length - b.contributors.length; + case 'participants': + return a.participants.length - b.participants.length; default: return 0; } @@ -410,16 +509,20 @@ export class OrgProjectsComponent { private matchesWorkspace(project: OrgLensProject, workspace: OrgProjectsWorkspaceId): boolean { switch (workspace) { - case 'all-projects': - return true; - case 'most-influential': - return project.technicalInfluence === 'leading' || project.technicalInfluence === 'contributing'; - case 'where-we-lead': - return project.technicalInfluence === 'leading'; case 'most-active': - default: // Active = not archived (excludes the demo "Jenkins" archived row with score 0). return project.influenceScore > 0; + case 'key-projects': + // "Key" = projects we lead or actively contribute to. + return project.technicalInfluence === 'leading' || project.technicalInfluence === 'contributing'; + case 'finos': + case 'cncf': + // Foundation-scoped workspaces match by foundation slug. + return project.foundation.slug === workspace; + case DEFAULT_ORG_PROJECTS_WORKSPACE_ID: + default: + // "All Projects with Activities" (and any custom workspace) shows every project with activity. + return true; } } @@ -457,9 +560,21 @@ export class OrgProjectsComponent { // Add-to-workspace writes to the per-user workspace project list; CRUD flow is a separate ticket. } - private readWorkspaceFromUrl(): OrgProjectsWorkspaceId { - const raw = this.route.snapshot.queryParamMap.get('workspace'); - return raw && VALID_ORG_PROJECTS_WORKSPACE_IDS.has(raw as OrgProjectsWorkspaceId) ? (raw as OrgProjectsWorkspaceId) : DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + private uniqueWorkspaceId(name: string): string { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') || 'workspace'; + const existing = new Set(this.workspaces().map((w) => w.id)); + if (!existing.has(base)) { + return base; + } + let suffix = 2; + while (existing.has(`${base}-${suffix}`)) { + suffix += 1; + } + return `${base}-${suffix}`; } private readEmployeesFromUrl(): string[] { diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts index bb83b826e..825cb08c4 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -3,7 +3,15 @@ // Generated with [Claude Code](https://claude.ai/code) -import type { InfluenceTrendDirection, OrgLensProject, OrgLensProjectFoundation, OrgLensProjectPerson, OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; +import type { + InfluenceTrend, + InfluenceTrendDirection, + OrgLensProject, + OrgLensProjectFoundation, + OrgLensProjectPerson, + OrgLensProjectsResponse, + ProjectHealthMetric, +} from '@lfx-one/shared/interfaces'; /** * Demo company data for the Org Lens Projects page (LFXV2-1883 / LFXV2-1884). @@ -33,11 +41,24 @@ function trendDirection(deltaPct: number): InfluenceTrendDirection { return deltaPct < -1 ? 'down' : 'flat'; } -function trend(deltaPct: number, series: number[]): { deltaPct: number; direction: InfluenceTrendDirection; series: number[] } { - return { deltaPct, direction: trendDirection(deltaPct), series }; +function round1(n: number): number { + return Math.round(n * 10) / 10; } -const DEMO_PROJECTS: OrgLensProject[] = [ +// Demo technical/ecosystem deltas are deterministic variations of the combined delta so the +// hover tooltip shows three distinct-but-plausible numbers. Real data supplies all three directly. +function trend(deltaPct: number, series: number[]): InfluenceTrend { + return { + deltaPct, + technicalDeltaPct: round1(deltaPct * 1.15), + ecosystemDeltaPct: round1(deltaPct * 0.8), + direction: trendDirection(deltaPct), + series, + }; +} + +// `description` + `healthMetrics` are injected by getDemoProjectsResponse so the 14 base rows stay terse. +const DEMO_PROJECTS: Omit[] = [ { slug: 'kubernetes', name: 'Kubernetes', @@ -49,8 +70,21 @@ const DEMO_PROJECTS: OrgLensProject[] = [ influenceScore: 92.4, priorYearScore: 78.2, trend: trend(18.2, [78, 80, 83, 85, 88, 90, 92]), - maintainers: [person('p1', 'Ada Lovelace'), person('p2', 'Grace Hopper'), person('p3', 'Alan Turing'), person('p4', 'Linus Park'), person('p5', 'Mira Chen')], - contributors: [person('p6', 'Tom Reyes'), person('p7', 'Nina Patel'), person('p8', 'Omar Diaz'), person('p9', 'Sara Kim'), person('p10', 'Wei Zhang'), person('p11', 'Eve Stone')], + maintainers: [ + person('p1', 'Ada Lovelace'), + person('p2', 'Grace Hopper'), + person('p3', 'Alan Turing'), + person('p4', 'Linus Park'), + person('p5', 'Mira Chen'), + ], + contributors: [ + person('p6', 'Tom Reyes'), + person('p7', 'Nina Patel'), + person('p8', 'Omar Diaz'), + person('p9', 'Sara Kim'), + person('p10', 'Wei Zhang'), + person('p11', 'Eve Stone'), + ], participants: [person('p12', 'Raj Singh'), person('p13', 'Lena Fox')], commits1y: 48210, changeDriver: { label: '+3 maintainers', direction: 'up' }, @@ -180,7 +214,7 @@ const DEMO_PROJECTS: OrgLensProject[] = [ logoUrl: '', foundation: LF_NETWORKING, health: 'at-risk', - technicalInfluence: 'participating', + technicalInfluence: 'silent', ecosystemInfluence: 'non-lf', influenceScore: 33.9, priorYearScore: 42.7, @@ -216,7 +250,7 @@ const DEMO_PROJECTS: OrgLensProject[] = [ foundation: OPENSSF, health: 'healthy', technicalInfluence: 'participating', - ecosystemInfluence: 'participating', + ecosystemInfluence: 'silent', influenceScore: 47.3, priorYearScore: 44.9, trend: trend(5.3, [44, 45, 45, 46, 46, 47, 47]), @@ -249,7 +283,7 @@ const DEMO_PROJECTS: OrgLensProject[] = [ logoUrl: '', foundation: CD_FOUNDATION, health: 'at-risk', - technicalInfluence: 'participating', + technicalInfluence: 'silent', ecosystemInfluence: 'non-lf', // Archived in our workspace — excluded from Most Decreases (current score 0). influenceScore: 0, @@ -280,13 +314,73 @@ const DEMO_PROJECTS: OrgLensProject[] = [ }, ]; +// Demo project logos sourced from each project's public GitHub org avatar (CDN-served, stable). +const LOGO_BY_SLUG: Record = { + kubernetes: 'https://github.com/kubernetes.png?size=80', + prometheus: 'https://github.com/prometheus.png?size=80', + envoy: 'https://github.com/envoyproxy.png?size=80', + opentelemetry: 'https://github.com/open-telemetry.png?size=80', + argo: 'https://github.com/argoproj.png?size=80', + pytorch: 'https://github.com/pytorch.png?size=80', + onnx: 'https://github.com/onnx.png?size=80', + onap: 'https://github.com/onap.png?size=80', + 'fd-io': 'https://github.com/FDio.png?size=80', + sigstore: 'https://github.com/sigstore.png?size=80', + 'in-toto': 'https://github.com/in-toto.png?size=80', + tekton: 'https://github.com/tektoncd.png?size=80', + jenkins: 'https://github.com/jenkinsci.png?size=80', + 'spiffe-spire': 'https://github.com/spiffe.png?size=80', +}; + +// Short descriptions shown in the health-detail popover (generic fallback covers any unmapped slug). +const DESCRIPTION_BY_SLUG: Record = { + kubernetes: 'Production-grade container orchestration for automating deployment, scaling, and management of containerized applications.', + prometheus: 'An open-source systems monitoring and alerting toolkit with a dimensional data model and a powerful query language.', + envoy: 'A high-performance open source edge and service proxy designed for cloud-native applications.', + opentelemetry: 'A collection of APIs, SDKs, and tools for instrumenting, generating, and collecting telemetry data.', + argo: 'Kubernetes-native workflow engine and GitOps continuous delivery tooling.', + pytorch: 'An open source machine learning framework that accelerates the path from research prototyping to production.', + onnx: 'An open standard for representing machine learning models, enabling interoperability across frameworks.', + onap: 'Open Network Automation Platform for orchestrating physical and virtual network functions.', + 'fd-io': 'Fast Data I/O: a high-performance IO services framework for dynamic networking workloads.', + sigstore: 'Free software signing and transparency to make the software supply chain more secure.', + 'in-toto': 'A framework to cryptographically secure the integrity of software supply chains.', + tekton: 'A flexible Kubernetes-native framework for building CI/CD systems.', + jenkins: 'The leading open source automation server for building, testing, and deploying software.', + 'spiffe-spire': 'A universal identity control plane that issues cryptographic workload identities to distributed systems.', +}; + +// Deterministic CHAOSS-style sub-scores so the health popover is stable across reloads. Real data supplies these. +function buildHealthMetrics(project: Omit): ProjectHealthMetric[] { + const baseByHealth: Record = { excellent: 84, healthy: 64, 'at-risk': 42 }; + const base = baseByHealth[project.health] ?? 60; + const seed = project.slug.length + Math.round(project.influenceScore); + const score = (offset: number): number => Math.max(22, Math.min(98, base + ((seed * (offset + 3)) % 26) - 10)); + return [ + { label: 'Contributors', value: score(1) }, + { label: 'Popularity', value: score(2) }, + { label: 'Development', value: score(3) }, + { label: 'Security', value: score(4) }, + ]; +} + /** Demo response for a single org. The org slug/name flow into the CSV export filename + header. */ export function getDemoProjectsResponse(orgUid: string, orgName: string): OrgLensProjectsResponse { return { - orgSlug: orgName ? orgName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : orgUid, + orgSlug: orgName + ? orgName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + : orgUid, orgName: orgName || 'Your organization', - // Static demo build timestamp (~2h ago) so the freshness label renders a stable relative value. + // Static demo build timestamp (~2h ago). dataUpdatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), - projects: DEMO_PROJECTS.map((project) => ({ ...project })), + projects: DEMO_PROJECTS.map((project) => ({ + ...project, + logoUrl: LOGO_BY_SLUG[project.slug] ?? project.logoUrl, + description: DESCRIPTION_BY_SLUG[project.slug] ?? `${project.name} is an open source project in the ${project.foundation.name} ecosystem.`, + healthMetrics: buildHealthMetrics(project), + })), }; } diff --git a/apps/lfx-one/src/styles.scss b/apps/lfx-one/src/styles.scss index c9f4e9403..52bd155dd 100644 --- a/apps/lfx-one/src/styles.scss +++ b/apps/lfx-one/src/styles.scss @@ -360,3 +360,15 @@ html { } } } + +// Org Lens projects — explanatory column tooltips need more width than PrimeNG's default 12.5rem cap. +// Tooltips are appended to , so they must be styled globally (component/Tailwind-scoped styles don't reach them). +// Higher specificity (`.p-tooltip.`) + !important so these win over PrimeNG's default 12.5rem cap +// regardless of stylesheet order. +.p-tooltip.lfx-tooltip-wide .p-tooltip-text { + max-width: 36rem !important; +} + +.p-tooltip.lfx-tooltip-nowrap .p-tooltip-text { + max-width: none !important; +} diff --git a/apps/lfx-one/tailwind.config.js b/apps/lfx-one/tailwind.config.js index 70ff68f4e..74b0c5841 100644 --- a/apps/lfx-one/tailwind.config.js +++ b/apps/lfx-one/tailwind.config.js @@ -24,6 +24,18 @@ export default { 'text-amber-500', 'text-purple-500', 'text-gray-500', + // Org Lens projects — influence band signal-strength bars (classes defined in @lfx-one/shared, not scanned here) + 'fill-emerald-500', + 'fill-blue-500', + 'fill-amber-500', + 'fill-red-500', + 'fill-gray-400', + 'fill-gray-200', + // Lighter tints for the unfilled signal bars + 'fill-emerald-200', + 'fill-blue-200', + 'fill-amber-200', + 'fill-red-200', ], theme: { container: { diff --git a/packages/shared/src/constants/org-lens-projects.constants.ts b/packages/shared/src/constants/org-lens-projects.constants.ts index 8f3c15b7b..50cfb04e3 100644 --- a/packages/shared/src/constants/org-lens-projects.constants.ts +++ b/packages/shared/src/constants/org-lens-projects.constants.ts @@ -2,58 +2,32 @@ // SPDX-License-Identifier: MIT import type { - FilterPillOption, HealthScore, InfluenceBand, - InfluenceSummaryMode, InfluenceTrendDirection, OrgProjectsSortField, + OrgProjectsWorkspace, OrgProjectsWorkspaceId, SortDirection, TagSeverity, } from '../interfaces'; import { lfxColors } from './colors.constants'; -/** Workspace presets shown in the Workspace select (`?workspace=`). `most-active` is the default. */ -export const ORG_PROJECTS_WORKSPACE_OPTIONS: ReadonlyArray<{ label: string; value: OrgProjectsWorkspaceId }> = [ - { label: 'Most Active Projects', value: 'most-active' }, - { label: 'All Projects', value: 'all-projects' }, - { label: 'Most Influential', value: 'most-influential' }, - { label: 'Where We Lead', value: 'where-we-lead' }, -]; - -export const DEFAULT_ORG_PROJECTS_WORKSPACE_ID: OrgProjectsWorkspaceId = 'most-active'; +/** The default workspace for every company: all projects with any activity. */ +export const DEFAULT_ORG_PROJECTS_WORKSPACE_ID: OrgProjectsWorkspaceId = 'all-activities'; -export const VALID_ORG_PROJECTS_WORKSPACE_IDS = new Set(['most-active', 'all-projects', 'most-influential', 'where-we-lead']); - -/** Influence Summary pill tabs (`?influenceTab=`). `influential` is the default. */ -export const ORG_PROJECTS_INFLUENCE_TABS: ReadonlyArray = [ - { id: 'influential', label: 'Most Influential' }, - { id: 'gains', label: 'Most Gains in Influence' }, - { id: 'decreases', label: 'Most Decreases in Influence' }, +// Every company starts with only the default workspace; users add/rename/delete their own on top. +export const DEFAULT_ORG_PROJECTS_WORKSPACES: ReadonlyArray = [ + { id: DEFAULT_ORG_PROJECTS_WORKSPACE_ID, name: 'All Projects with Activities' }, ]; -export const DEFAULT_INFLUENCE_SUMMARY_MODE: InfluenceSummaryMode = 'influential'; - -export const VALID_INFLUENCE_SUMMARY_MODES = new Set(['influential', 'gains', 'decreases']); - -/** Number of cards rendered per Influence Summary mode. */ -export const INFLUENCE_SUMMARY_CARD_COUNT = 3; - /** Display labels for influence bands. */ export const INFLUENCE_BAND_LABELS: Record = { leading: 'Leading', contributing: 'Contributing', participating: 'Participating', - 'non-lf': 'Non-LF', -}; - -/** Tag/badge severity per influence band (drives band-chip color). */ -export const INFLUENCE_BAND_SEVERITY: Record = { - leading: 'success', - contributing: 'info', - participating: 'warn', - 'non-lf': 'secondary', + silent: 'Silent', + 'non-lf': 'Non-LF Project', }; /** Sparkline / delta color per trend direction (brand scale values; never hard-coded hex). */ @@ -63,14 +37,33 @@ export const INFLUENCE_TREND_COLOR: Record = { flat: lfxColors.gray[400], }; -/** Sort rank for influence bands (strongest highest), used for the table tie-break. */ +/** Sort rank for influence bands (strongest highest); also the number of filled signal bars (0–4). */ export const INFLUENCE_BAND_RANK: Record = { - leading: 3, - contributing: 2, - participating: 1, + leading: 4, + contributing: 3, + participating: 2, + silent: 1, 'non-lf': 0, }; +/** SVG fill class per influence band for the signal-strength bars icon (filled bars = rank; Non-LF has 0 + a slash). */ +export const INFLUENCE_BAND_BAR_FILL_CLASS: Record = { + leading: 'fill-emerald-500', + contributing: 'fill-blue-500', + participating: 'fill-amber-500', + silent: 'fill-red-500', + 'non-lf': 'fill-gray-400', +}; + +/** Lighter fill for the unfilled signal bars — a tint of the band color (per the org dashboard design). */ +export const INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT: Record = { + leading: 'fill-emerald-200', + contributing: 'fill-blue-200', + participating: 'fill-amber-200', + silent: 'fill-red-200', + 'non-lf': 'fill-gray-200', +}; + /** Display labels for health scores. */ export const HEALTH_SCORE_LABELS: Record = { excellent: 'Excellent', @@ -91,18 +84,16 @@ export const ORG_PROJECTS_PAGE_SIZE_OPTIONS: readonly number[] = [10, 25, 50]; export const DEFAULT_ORG_PROJECTS_PAGE_SIZE = 25; /** Default Projects-table sort: Influence Trend, descending. */ -export const DEFAULT_ORG_PROJECTS_SORT_FIELD: OrgProjectsSortField = 'influenceTrend'; +export const DEFAULT_ORG_PROJECTS_SORT_FIELD: OrgProjectsSortField = 'contributors'; export const DEFAULT_ORG_PROJECTS_SORT_DIR: SortDirection = 'desc'; export const VALID_ORG_PROJECTS_SORT_FIELDS = new Set([ 'name', - 'foundation', 'health', 'technicalInfluence', 'ecosystemInfluence', 'influenceTrend', + 'contributors', + 'participants', ]); - -/** Maximum avatars shown in a table avatar stack before collapsing into a `+N` chip. */ -export const ORG_PROJECTS_AVATAR_STACK_LIMIT = 4; diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts index 513e5f838..366888481 100644 --- a/packages/shared/src/interfaces/org-lens-projects.interface.ts +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -11,7 +11,7 @@ */ /** Influence band per the markup-mu methodology (Boysel et al.). Declared strongest → weakest. */ -export type InfluenceBand = 'leading' | 'contributing' | 'participating' | 'non-lf'; +export type InfluenceBand = 'leading' | 'contributing' | 'participating' | 'silent' | 'non-lf'; /** CHAOSS-derived project health classification (via LFX Insights). */ export type HealthScore = 'excellent' | 'healthy' | 'at-risk'; @@ -43,6 +43,10 @@ export interface OrgLensProjectFoundation { export interface InfluenceTrend { /** Percent change of the combined influence score, rolling 365 vs prior 365. */ deltaPct: number; + /** Percent change of the technical influence score, rolling 365 vs prior 365. */ + technicalDeltaPct: number; + /** Percent change of the ecosystem influence score, rolling 365 vs prior 365. */ + ecosystemDeltaPct: number; /** Direction bucket derived from `deltaPct` (drives green/red/neutral styling). */ direction: InfluenceTrendDirection; /** Ordered score samples (oldest → newest) for the sparkline. */ @@ -89,6 +93,18 @@ export interface OrgLensProject { commits1y: number; /** Largest single driver of the influence delta (used by Influence Summary cards). */ changeDriver: ChangeDriver; + /** Short project description shown in the health-detail popover. */ + description: string; + /** CHAOSS-style health sub-scores (0–100) shown in the health-detail popover. */ + healthMetrics: ProjectHealthMetric[]; +} + +/** A single CHAOSS health sub-score (0–100) shown in the health-detail popover. */ +export interface ProjectHealthMetric { + /** Metric name, e.g. `Contributors`, `Popularity`, `Development`, `Security`. */ + label: string; + /** Score on a 0–100 scale, rendered as a progress bar. */ + value: number; } /** Top-level response for the Org Lens Projects page. */ @@ -104,21 +120,17 @@ export interface OrgLensProjectsResponse { } /** Workspace preset / saved-filter identifier (`?workspace=`). */ -export type OrgProjectsWorkspaceId = 'most-active' | 'all-projects' | 'most-influential' | 'where-we-lead'; +/** Workspace identifier. System presets use stable slugs; user-created workspaces get generated slugs. */ +export type OrgProjectsWorkspaceId = string; + +/** A saved Org Lens workspace (filter preset). Each company is seeded with the default; users add/rename/delete their own. */ +export interface OrgProjectsWorkspace { + id: OrgProjectsWorkspaceId; + name: string; +} /** Sortable Projects-table column keys (`?sort=`). */ -export type OrgProjectsSortField = 'name' | 'foundation' | 'health' | 'technicalInfluence' | 'ecosystemInfluence' | 'influenceTrend'; +export type OrgProjectsSortField = 'name' | 'health' | 'technicalInfluence' | 'ecosystemInfluence' | 'influenceTrend' | 'contributors' | 'participants'; /** Sort direction (`?dir=`). */ export type SortDirection = 'asc' | 'desc'; - -/** Influence Summary pill-tab mode (`?influenceTab=`). */ -export type InfluenceSummaryMode = 'influential' | 'gains' | 'decreases'; - -/** A single Influence Summary card, derived from an `OrgLensProject` for the active mode. */ -export interface InfluenceSummaryCard { - /** The project this card represents. */ - project: OrgLensProject; - /** Mode-specific primary metric line (e.g. "Influence score: 87.3 (Leading)", "+14.2 points (1y)"). */ - primaryMetric: string; -} From 27f4432a3f92858f24b39e8a53f2ccac4757a01f Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 10 Jun 2026 15:17:20 -0600 Subject: [PATCH 03/12] fix(review): address post-commit review findings - Precompute signal-bar geometry + trend tooltip HTML into a row view-model (OrgProjectsTableRow) so the table template reads properties instead of calling builder methods every change-detection cycle; lift the influence-column tooltip to a static field. - Make the workspace dialog visibility a model() with [(visible)] instead of a signal + split [visible]/(visibleChange) binding. - Clear the health-popover hide timer on DestroyRef.onDestroy so a pending setTimeout can't fire after teardown. - Make the Influence Trend sparkline tooltip keyboard-accessible (focusable host with role + aria-label trend summary). LFXV2-1883 Signed-off-by: daniel qualls --- .../org-projects/org-projects.component.html | 22 ++++++----- .../org-projects/org-projects.component.ts | 39 +++++++++++++++---- .../constants/org-lens-projects.constants.ts | 2 +- .../interfaces/org-lens-projects.interface.ts | 23 ++++++++++- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html index 005396627..ee3257afe 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -97,7 +97,7 @@

} @else { @@ -155,7 +155,7 @@

@@ -235,7 +235,7 @@

{{ bandLabel(project.ecosystemInfluence) }} - +
  • Technical influence examines code activities (commits, PRs) while ecosystem influence examines non-code collaboration activities (documentation, committees, meetings, events).
  • Comparing our company's share of these activities to the project total indicates greater influence in the project.
  • `; // Minimal Chart.js line config for the Influence Trend sparkline (no axes, points, legend, or tooltip). protected readonly sparklineOptions = { responsive: true, @@ -117,7 +121,8 @@ export class OrgProjectsComponent { protected readonly workspaces = signal([...DEFAULT_ORG_PROJECTS_WORKSPACES]); /** Workspace being renamed/deleted in the settings dialog; `null` while the dialog adds a new one. */ protected readonly editingWorkspace = signal(null); - protected readonly workspaceDialogOpen = signal(false); + /** Two-way visibility for the workspace add/settings dialog (`[(visible)]`). */ + protected readonly workspaceDialogOpen = model(false); /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ private readonly reload = signal(0); /** Action menu items rebuilt per row when the kebab is opened. */ @@ -166,6 +171,8 @@ export class OrgProjectsComponent { protected readonly filteredProjects = this.initFilteredProjects(); /** `filteredProjects` ordered by the active sort (pinned rows float to the top). */ protected readonly sortedProjects = this.initSortedProjects(); + /** Table rows: sorted projects enriched with precomputed bar geometry + tooltip HTML (keeps logic out of the template). */ + protected readonly rows = this.initRows(); protected readonly totalRecords = computed(() => this.sortedProjects().length); public constructor() { @@ -182,6 +189,9 @@ export class OrgProjectsComponent { replaceUrl: true, }); }); + + // Clear any pending health-popover hide timer on teardown so it can't fire after destroy. + inject(DestroyRef).onDestroy(() => this.cancelHealthHide()); } // Public methods @@ -320,7 +330,7 @@ export class OrgProjectsComponent { // Signal-strength bars for an influence band: filled count = rank (Leading 4 → Silent 1 → Non-LF 0), // colored by band; remaining bars faded. Non-LF (0 filled) gets a diagonal slash from the template. // Geometry mirrors the LFX Insights signal-bar icon: 4 evenly spaced, ascending, rounded bars in a 16×16 box. - protected bandBars(band: InfluenceBand): { x: number; y: number; w: number; h: number; colorClass: string }[] { + protected bandBars(band: InfluenceBand): OrgProjectsSignalBar[] { const heights = [5, 8.3, 11.6, 15]; const barWidth = 2.6; const gap = 1.8; @@ -334,10 +344,6 @@ export class OrgProjectsComponent { colorClass: i < filled ? INFLUENCE_BAND_BAR_FILL_CLASS[band] : INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT[band], })); } - // Explanatory hover for the Technical / Ecosystem influence column headers. - protected influenceColumnTooltip(): string { - return `
    • Technical influence examines code activities (commits, PRs) while ecosystem influence examines non-code collaboration activities (documentation, committees, meetings, events).
    • Comparing our company's share of these activities to the project total indicates greater influence in the project.
    `; - } protected openHealth(event: Event, project: OrgLensProject, popover: Popover): void { this.cancelHealthHide(); this.activeHealthProject.set(project); @@ -378,6 +384,12 @@ export class OrgProjectsComponent { } return 'text-gray-300'; } + // Plain-text trend summary for screen readers / keyboard focus on the sparkline. + protected trendAriaLabel(project: OrgLensProject): string { + const t = project.trend; + const fmt = (v: number): string => `${v > 0 ? '+' : ''}${v}%`; + return `Influence trend over the past year — combined ${fmt(t.deltaPct)}, technical ${fmt(t.technicalDeltaPct)}, ecosystem ${fmt(t.ecosystemDeltaPct)}.`; + } protected isPinned(slug: string): boolean { return this.pinnedSlugs().has(slug); } @@ -468,6 +480,19 @@ export class OrgProjectsComponent { }); } + // Enrich each sorted project with presentation values so the template only reads properties (no in-template logic). + private initRows(): Signal { + return computed(() => + this.sortedProjects().map((project) => ({ + ...project, + technicalBars: this.bandBars(project.technicalInfluence), + ecosystemBars: this.bandBars(project.ecosystemInfluence), + trendTooltipHtml: this.trendTooltip(project), + trendAriaLabel: this.trendAriaLabel(project), + })) + ); + } + private compareProjects(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField, dir: SortDirection): number { const primary = this.compareByField(a, b, field); const directed = dir === 'asc' ? primary : -primary; diff --git a/packages/shared/src/constants/org-lens-projects.constants.ts b/packages/shared/src/constants/org-lens-projects.constants.ts index 50cfb04e3..2ece22c8a 100644 --- a/packages/shared/src/constants/org-lens-projects.constants.ts +++ b/packages/shared/src/constants/org-lens-projects.constants.ts @@ -83,7 +83,7 @@ export const ORG_PROJECTS_PAGE_SIZE_OPTIONS: readonly number[] = [10, 25, 50]; export const DEFAULT_ORG_PROJECTS_PAGE_SIZE = 25; -/** Default Projects-table sort: Influence Trend, descending. */ +/** Default Projects-table sort: Contributors descending (tie-break participants desc, then name). */ export const DEFAULT_ORG_PROJECTS_SORT_FIELD: OrgProjectsSortField = 'contributors'; export const DEFAULT_ORG_PROJECTS_SORT_DIR: SortDirection = 'desc'; diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts index 366888481..146ae9641 100644 --- a/packages/shared/src/interfaces/org-lens-projects.interface.ts +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -119,8 +119,7 @@ export interface OrgLensProjectsResponse { projects: OrgLensProject[]; } -/** Workspace preset / saved-filter identifier (`?workspace=`). */ -/** Workspace identifier. System presets use stable slugs; user-created workspaces get generated slugs. */ +/** Workspace identifier (`?workspace=`). The default uses a stable slug; user-created workspaces get generated slugs. */ export type OrgProjectsWorkspaceId = string; /** A saved Org Lens workspace (filter preset). Each company is seeded with the default; users add/rename/delete their own. */ @@ -134,3 +133,23 @@ export type OrgProjectsSortField = 'name' | 'health' | 'technicalInfluence' | 'e /** Sort direction (`?dir=`). */ export type SortDirection = 'asc' | 'desc'; + +/** A single rounded bar in the influence signal-strength icon (precomputed for the table view-model). */ +export interface OrgProjectsSignalBar { + x: number; + y: number; + w: number; + h: number; + /** Tailwind `fill-*` class for the bar. */ + colorClass: string; +} + +/** Projects-table row: the project plus presentation values precomputed off the template hot path. */ +export interface OrgProjectsTableRow extends OrgLensProject { + technicalBars: OrgProjectsSignalBar[]; + ecosystemBars: OrgProjectsSignalBar[]; + /** Pre-rendered HTML for the Influence Trend hover tooltip. */ + trendTooltipHtml: string; + /** Plain-text trend summary for screen readers / keyboard focus. */ + trendAriaLabel: string; +} From 6cd402540c0d74936fd0a0bbd6fde087d512c5eb Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 10 Jun 2026 15:43:43 -0600 Subject: [PATCH 04/12] test(org-lens): add projects page e2e coverage Adds a Playwright e2e spec for the Org Lens Projects page (LFXV2-1883) following the org-events-dashboard pattern: stubs the personas endpoint for org context, skips gracefully when auth / the org-lens flag is unavailable, and covers demo-data table render, column-header sort + URL persistence, the workspace dropdown / add-workspace dialog, and the health-detail hover popover. The repo has no unit-test harness (no Karma/Jasmine/Jest, no test target); Playwright e2e is the established convention for these pages. LFXV2-1883 Signed-off-by: daniel qualls --- apps/lfx-one/e2e/org-projects.spec.ts | 107 ++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 apps/lfx-one/e2e/org-projects.spec.ts diff --git a/apps/lfx-one/e2e/org-projects.spec.ts b/apps/lfx-one/e2e/org-projects.spec.ts new file mode 100644 index 000000000..010f62c74 --- /dev/null +++ b/apps/lfx-one/e2e/org-projects.spec.ts @@ -0,0 +1,107 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { expect, Page, test } from '@playwright/test'; + +const ORG_PROJECTS_URL = '/org/projects'; +const DATA_LOAD_TIMEOUT = 30_000; +const MOCK_ACCOUNT_ID = '0014100000Te2QjAAJ'; +const MOCK_UID = '4c46585f-878c-8285-b2e9-2dbfc38ddd9b'; + +test.setTimeout(120_000); + +function skipWhenAuthMissing(page: Page): void { + try { + const { hostname } = new URL(page.url()); + if (hostname === 'auth0.com' || hostname.endsWith('.auth0.com')) { + test.skip(true, 'TEST_USERNAME / TEST_PASSWORD not configured — see global-setup.ts'); + } + } catch { + // Let malformed URLs fail naturally. + } +} + +// The Projects page renders from a client-side demo-data service (no projects API yet), so the only +// stub needed is the personas endpoint to give the org context an account to select. +async function stubOrgContext(page: Page): Promise { + await page.route('**/api/user/personas*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + personas: ['contributor'], + personaProjects: {}, + projects: [], + organizations: [ + { + accountId: MOCK_ACCOUNT_ID, + accountName: 'Red Hat LLC', + accountSlug: 'red-hat-llc', + membershipTier: '', + uid: MOCK_UID, + }, + ], + isRootWriter: false, + }), + }) + ); +} + +async function gotoOrgProjectsPage(page: Page): Promise { + await stubOrgContext(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + skipWhenAuthMissing(page); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.goto(ORG_PROJECTS_URL, { waitUntil: 'domcontentloaded' }); + skipWhenAuthMissing(page); + await expect(page).not.toHaveURL(/auth0\.com/); + + if (!page.url().includes('/org/projects')) { + test.skip(true, 'org-lens-enabled flag appears off — /org/projects redirected away'); + } +} + +test.describe('Org Projects', () => { + test('renders the projects table with demo data', async ({ page }) => { + await gotoOrgProjectsPage(page); + + await expect(page.getByTestId('org-projects-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + await expect(page.getByTestId('org-projects-table')).toBeVisible(); + // Demo dataset always includes Kubernetes (Leading / Leading). + const kubernetesRow = page.getByTestId('org-projects-row-kubernetes'); + await expect(kubernetesRow).toBeVisible(); + await expect(kubernetesRow.getByText('Leading').first()).toBeVisible(); + await expect(page.getByTestId('org-projects-export-csv')).toBeVisible(); + }); + + test('sorts by project name from the column header (persists to the URL)', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-sort-name').click(); + await expect(page).toHaveURL(/[?&]sort=name/); + }); + + test('opens the workspace dropdown and the add-workspace dialog', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-workspace-trigger').click(); + // Each company is seeded with only the default workspace. + await expect(page.getByTestId('org-projects-workspace-option-all-activities')).toBeVisible(); + + await page.getByTestId('org-projects-add-workspace').click(); + await expect(page.getByTestId('org-projects-workspace-dialog')).toBeVisible(); + await expect(page.getByTestId('org-projects-workspace-save')).toBeVisible(); + }); + + test('reveals the LFX Insights health detail on hover', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-row-kubernetes')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-health-kubernetes').hover(); + const popover = page.getByTestId('org-projects-health-popover'); + await expect(popover).toBeVisible(); + await expect(popover.getByRole('link', { name: /LFX Insights/ })).toBeVisible(); + }); +}); From 496545a28006699355c1e613bfe1f64920a29795 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:04:54 +0000 Subject: [PATCH 05/12] fix(org-projects): address review comments --- .../org/org-projects/org-projects.component.html | 10 +++++++--- .../org/org-projects/org-projects.component.ts | 8 +++++++- .../app/shared/services/org-lens-projects.demo-data.ts | 2 +- packages/shared/src/utils/file.utils.ts | 9 +++++---- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html index ee3257afe..2aefc144d 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -223,13 +223,17 @@

    - + diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index 153856248..7cf336469 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -454,11 +454,17 @@ export class OrgProjectsComponent { const all = this.response()?.projects ?? []; const workspace = this.selectedWorkspaceId(); const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; + const employees = this.formValue().employees?.filter(Boolean) ?? []; const hidden = this.hiddenSlugs(); return all .filter((p) => !hidden.has(p.slug)) .filter((p) => this.matchesWorkspace(p, workspace)) - .filter((p) => foundation === ALL_FOUNDATIONS || p.foundation.slug === foundation); + .filter((p) => foundation === ALL_FOUNDATIONS || p.foundation.slug === foundation) + .filter( + (p) => + employees.length === 0 || + [...p.maintainers, ...p.contributors, ...p.participants].some((person) => employees.includes(person.id)) + ); }); } diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts index 825cb08c4..e07ba463d 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -23,7 +23,7 @@ import type { * with `influenceScore: 0` (excluded from Most Decreases). */ -// Empty avatar / logo URLs fall back to initials in the avatar/logo components — no network needed for demo. +// Person avatar URLs are intentionally empty (initials fallback). Project logos are populated separately from GitHub avatar URLs. function person(id: string, name: string): OrgLensProjectPerson { return { id, name, avatarUrl: '' }; } diff --git a/packages/shared/src/utils/file.utils.ts b/packages/shared/src/utils/file.utils.ts index 795648152..631cf88d5 100644 --- a/packages/shared/src/utils/file.utils.ts +++ b/packages/shared/src/utils/file.utils.ts @@ -284,14 +284,15 @@ export function sanitizeFilename(filename: string, maxLength: number = 255): str } /** - * Escape a single CSV cell per RFC 4180: wrap in quotes when the value contains a - * comma, quote, or newline, and double any embedded quotes. + * Escape a single CSV cell per RFC 4180 and neutralize formula-injection prefixes. + * Cells starting with =, +, -, @, TAB, or CR are prefixed with a leading apostrophe. * @param value - Raw cell value * @returns CSV-safe cell string */ function escapeCsvCell(value: string | number): string { - const str = String(value ?? ''); - return /[",\r\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str; + const raw = String(value ?? ''); + const safe = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; } /** From 64f96a8da8d66d0329778d758d467843912c5472 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Wed, 10 Jun 2026 16:19:04 -0600 Subject: [PATCH 06/12] fix(review): fall back to orgUid after slug sanitization Addresses a CodeRabbit finding: a punctuation-only org name sanitizes to an empty slug, so compute the slug first and fall back to orgUid only when the result is empty (not just when orgName is falsy). LFXV2-1883 Signed-off-by: daniel qualls --- .../shared/services/org-lens-projects.demo-data.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts index e07ba463d..0c2c361be 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -366,13 +366,14 @@ function buildHealthMetrics(project: Omit Date: Thu, 11 Jun 2026 14:00:50 +0000 Subject: [PATCH 07/12] perf(org-projects): cache sparklineData() per project via WeakMap --- .../org/org-projects/org-projects.component.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index 7cf336469..b637489ba 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -87,6 +87,8 @@ export class OrgProjectsComponent { private readonly projectsService = inject(OrgLensProjectsService); /** Pending hide timer for the health popover (lets the cursor cross into the popover). */ private healthHideTimer: ReturnType | null = null; + /** Stable chart-data cache keyed by project reference — avoids reallocating on every template call. */ + private readonly sparklineCache = new WeakMap(); // Configuration protected readonly pageSizeOptions = [...ORG_PROJECTS_PAGE_SIZE_OPTIONS]; @@ -394,10 +396,16 @@ export class OrgProjectsComponent { return this.pinnedSlugs().has(slug); } protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { - return { + const cached = this.sparklineCache.get(project); + if (cached) { + return cached; + } + const data = { labels: project.trend.series.map((_, i) => String(i)), datasets: [{ data: project.trend.series, borderColor: INFLUENCE_TREND_COLOR[project.trend.direction], fill: false }], }; + this.sparklineCache.set(project, data); + return data; } // Private initializers From 9de507faef26bd47dd33e07cc511451805131fb7 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 11 Jun 2026 09:36:43 -0600 Subject: [PATCH 08/12] feat(org-lens): add project dialog; refine row and workspace actions - Add an 'Add project(s)' dialog (shared-company banner, searchable multi-select with project logos, chips, confirm) that adds projects to the current workspace; selections merge into the table from a demo catalog exposed via the service seam. - Add an optional optionImage input to the lfx-multi-select wrapper so dropdown options can show a logo (backward-compatible). - Reduce the row kebab to a single 'Hide project from workspace' action; remove the now-unused pin feature. - Always show 'Delete workspace' in the workspace settings modal; re-seed the default if the last workspace is removed. - Widen the column-header hover tooltips (min-width floor on the container to beat PrimeNG's inline fit-content) so the explainer text reads on one block. LFXV2-1883 Signed-off-by: daniel qualls --- .../org-projects/org-projects.component.html | 46 ++++++++-- .../org-projects/org-projects.component.ts | 88 +++++++------------ .../multi-select/multi-select.component.html | 19 ++-- .../multi-select/multi-select.component.ts | 3 + .../services/org-lens-projects.demo-data.ts | 49 +++++++++++ .../services/org-lens-projects.service.ts | 14 ++- apps/lfx-one/src/styles.scss | 15 +++- 7 files changed, 160 insertions(+), 74 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html index 2aefc144d..3bbe31aa3 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -72,7 +72,7 @@

    @@ -213,12 +213,7 @@

    - - {{ project.name }} - @if (isPinned(project.slug)) { - - } - + {{ project.name }} @@ -399,9 +394,9 @@

    - @if (editingWorkspace() && workspaces().length > 1) { + @if (editingWorkspace()) { +
    +
    + + This will be shared across all users in your company. +
    + +
    + + +
    +
    + diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index b637489ba..35d64b567 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -112,12 +112,17 @@ export class OrgProjectsComponent { protected readonly workspaceForm = new FormGroup({ name: new FormControl('', { nonNullable: true }), }); + /** Selected project slugs for the "Add project(s)" dialog. */ + protected readonly addProjectsForm = new FormGroup({ + projects: new FormControl([], { nonNullable: true }), + }); + /** Catalog of projects that can be added to the workspace (with logos for the multi-select). */ + protected readonly addableProjectOptions = this.projectsService.getAddableProjectOptions(); // Writable Signals protected readonly loading = signal(false); protected readonly error = signal(false); - /** Per-user workspace pin/hide state (client-only; never mutates the foundation catalog). */ - protected readonly pinnedSlugs = signal>(new Set()); + /** Slugs hidden from the current workspace (client-only; never mutates the foundation catalog). */ protected readonly hiddenSlugs = signal>(new Set()); /** Shared workspaces (seeded presets + user-created); editable via the workspace dropdown. */ protected readonly workspaces = signal([...DEFAULT_ORG_PROJECTS_WORKSPACES]); @@ -125,6 +130,10 @@ export class OrgProjectsComponent { protected readonly editingWorkspace = signal(null); /** Two-way visibility for the workspace add/settings dialog (`[(visible)]`). */ protected readonly workspaceDialogOpen = model(false); + /** Two-way visibility for the "Add project(s)" dialog (`[(visible)]`). */ + protected readonly addProjectsDialogOpen = model(false); + /** Projects added to the current workspace via the Add Project dialog (client-only demo state). */ + protected readonly addedProjects = signal([]); /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ private readonly reload = signal(0); /** Action menu items rebuilt per row when the kebab is opened. */ @@ -240,8 +249,19 @@ export class OrgProjectsComponent { return this.sortDir() === 'asc' ? 'fa-solid fa-sort-up text-blue-500' : 'fa-solid fa-sort-down text-blue-500'; } - protected openFindProject(): void { - // +Find project opens an add-project modal whose internals ship in a separate ticket. + protected openAddProjects(): void { + this.addProjectsForm.setValue({ projects: [] }); + this.addProjectsDialogOpen.set(true); + } + + protected confirmAddProjects(): void { + const slugs = this.addProjectsForm.getRawValue().projects; + const existing = new Set([...(this.response()?.projects ?? []), ...this.addedProjects()].map((p) => p.slug)); + const additions = this.projectsService.buildAddedProjects(slugs).filter((p) => !existing.has(p.slug)); + if (additions.length) { + this.addedProjects.update((prev) => [...prev, ...additions]); + } + this.addProjectsDialogOpen.set(false); } protected selectWorkspace(id: OrgProjectsWorkspaceId): void { @@ -283,14 +303,15 @@ export class OrgProjectsComponent { protected deleteWorkspace(): void { const editing = this.editingWorkspace(); - // Always keep at least one workspace so the company never ends up with none. - if (!editing || this.workspaces().length <= 1) { + if (!editing) { return; } + // Re-seed the default if the last workspace is removed so the company is never left with none. const remaining = this.workspaces().filter((w) => w.id !== editing.id); - this.workspaces.set(remaining); - if (this.selectedWorkspaceId() === editing.id) { - this.selectWorkspace(remaining[0].id); + const next = remaining.length > 0 ? remaining : [...DEFAULT_ORG_PROJECTS_WORKSPACES]; + this.workspaces.set(next); + if (!next.some((w) => w.id === this.selectedWorkspaceId())) { + this.selectWorkspace(next[0].id); } this.workspaceDialogOpen.set(false); } @@ -392,9 +413,6 @@ export class OrgProjectsComponent { const fmt = (v: number): string => `${v > 0 ? '+' : ''}${v}%`; return `Influence trend over the past year — combined ${fmt(t.deltaPct)}, technical ${fmt(t.technicalDeltaPct)}, ecosystem ${fmt(t.ecosystemDeltaPct)}.`; } - protected isPinned(slug: string): boolean { - return this.pinnedSlugs().has(slug); - } protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { const cached = this.sparklineCache.get(project); if (cached) { @@ -459,7 +477,7 @@ export class OrgProjectsComponent { private initFilteredProjects(): Signal { return computed(() => { - const all = this.response()?.projects ?? []; + const all = [...(this.response()?.projects ?? []), ...this.addedProjects()]; const workspace = this.selectedWorkspaceId(); const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; const employees = this.formValue().employees?.filter(Boolean) ?? []; @@ -468,11 +486,7 @@ export class OrgProjectsComponent { .filter((p) => !hidden.has(p.slug)) .filter((p) => this.matchesWorkspace(p, workspace)) .filter((p) => foundation === ALL_FOUNDATIONS || p.foundation.slug === foundation) - .filter( - (p) => - employees.length === 0 || - [...p.maintainers, ...p.contributors, ...p.participants].some((person) => employees.includes(person.id)) - ); + .filter((p) => employees.length === 0 || [...p.maintainers, ...p.contributors, ...p.participants].some((person) => employees.includes(person.id))); }); } @@ -481,15 +495,7 @@ export class OrgProjectsComponent { const projects = [...this.filteredProjects()]; const field = this.sortField(); const dir = this.sortDir(); - const pinned = this.pinnedSlugs(); - projects.sort((a, b) => { - const aPinned = pinned.has(a.slug); - const bPinned = pinned.has(b.slug); - if (aPinned !== bPinned) { - return aPinned ? -1 : 1; - } - return this.compareProjects(a, b, field, dir); - }); + projects.sort((a, b) => this.compareProjects(a, b, field, dir)); return projects; }); } @@ -566,39 +572,13 @@ export class OrgProjectsComponent { } private buildRowMenu(project: OrgLensProject): MenuItem[] { - const pinned = this.isPinned(project.slug); - return [ - { - label: pinned ? 'Unpin from top' : 'Pin to top', - icon: pinned ? 'fa-light fa-thumbtack-slash' : 'fa-light fa-thumbtack', - command: () => this.togglePin(project.slug), - }, - { label: 'Open detail', icon: 'fa-light fa-arrow-up-right-from-square', command: () => this.openDetail(project) }, - { label: 'Add to workspace', icon: 'fa-light fa-plus', command: () => this.addToWorkspace() }, - { label: 'Hide from this workspace', icon: 'fa-light fa-eye-slash', command: () => this.hideFromWorkspace(project.slug) }, - ]; - } - - private togglePin(slug: string): void { - this.pinnedSlugs.update((set) => { - const next = new Set(set); - if (next.has(slug)) { - next.delete(slug); - } else { - next.add(slug); - } - return next; - }); + return [{ label: 'Hide project from workspace', icon: 'fa-light fa-eye-slash', command: () => this.hideFromWorkspace(project.slug) }]; } private hideFromWorkspace(slug: string): void { this.hiddenSlugs.update((set) => new Set(set).add(slug)); } - private addToWorkspace(): void { - // Add-to-workspace writes to the per-user workspace project list; CRUD flow is a separate ticket. - } - private uniqueWorkspaceId(name: string): string { const base = name diff --git a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html index 9fafc7133..889b0463b 100644 --- a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html +++ b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html @@ -15,13 +15,22 @@ [size]="size()" [styleClass]="styleClass()" class="w-full"> - @if (optionSubLabel(); as subKey) { + @if (optionSubLabel() || optionImage()) { -
    - {{ option[optionLabel()] }} - @if (option[subKey]) { - {{ option[subKey] }} +
    + @if (optionImage(); as imgKey) { + @if (option[imgKey]) { + + } } +
    + {{ option[optionLabel()] }} + @if (optionSubLabel(); as subKey) { + @if (option[subKey]) { + {{ option[subKey] }} + } + } +
    } diff --git a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts index a3453c7f1..211d81d52 100644 --- a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts +++ b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts @@ -21,6 +21,9 @@ export class MultiSelectComponent { // list. Selected chips still show only optionLabel. When undefined the // dropdown renders default p-multiSelect items. public readonly optionSubLabel = input(undefined); + // Optional key for a per-option logo/image URL shown before the label in the dropdown list. + // When undefined (and no optionSubLabel) the dropdown renders default p-multiSelect items. + public readonly optionImage = input(undefined); public readonly placeholder = input('Select'); public readonly showToggleAll = input(true); public readonly appendTo = input('body'); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts index 0c2c361be..3ff2cf717 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -385,3 +385,52 @@ export function getDemoProjectsResponse(orgUid: string, orgName: string): OrgLen })), }; } + +// Demo catalog of projects a user can add to a workspace (distinct from the seeded DEMO_PROJECTS). +const ADDABLE_PROJECTS: readonly { slug: string; name: string; foundation: OrgLensProjectFoundation; org: string }[] = [ + { slug: 'cilium', name: 'Cilium', foundation: CNCF, org: 'cilium' }, + { slug: 'istio', name: 'Istio', foundation: CNCF, org: 'istio' }, + { slug: 'linkerd', name: 'Linkerd', foundation: CNCF, org: 'linkerd' }, + { slug: 'helm', name: 'Helm', foundation: CNCF, org: 'helm' }, + { slug: 'flux', name: 'Flux', foundation: CNCF, org: 'fluxcd' }, + { slug: 'vitess', name: 'Vitess', foundation: CNCF, org: 'vitessio' }, + { slug: 'etcd', name: 'etcd', foundation: CNCF, org: 'etcd-io' }, + { slug: 'coredns', name: 'CoreDNS', foundation: CNCF, org: 'coredns' }, + { slug: 'keda', name: 'KEDA', foundation: CNCF, org: 'kedacore' }, + { slug: 'dapr', name: 'Dapr', foundation: CNCF, org: 'dapr' }, + { slug: 'backstage', name: 'Backstage', foundation: CNCF, org: 'backstage' }, + { slug: 'kubeflow', name: 'Kubeflow', foundation: LF_AI, org: 'kubeflow' }, +]; + +function addableLogo(org: string): string { + return `https://github.com/${org}.png?size=80`; +} + +/** Options for the "Add project(s)" multi-select (value=slug, label=name, logoUrl for the option icon). */ +export function getAddableProjectOptions(): { value: string; label: string; logoUrl: string }[] { + return ADDABLE_PROJECTS.map((p) => ({ value: p.slug, label: p.name, logoUrl: addableLogo(p.org) })); +} + +/** Build full demo project rows for the given slugs (newly added → no org activity yet). */ +export function buildAddedProjects(slugs: readonly string[]): OrgLensProject[] { + return ADDABLE_PROJECTS.filter((p) => slugs.includes(p.slug)).map((p) => { + const base: Omit = { + slug: p.slug, + name: p.name, + logoUrl: addableLogo(p.org), + foundation: p.foundation, + health: 'healthy', + technicalInfluence: 'participating', + ecosystemInfluence: 'silent', + influenceScore: 12, + priorYearScore: 12, + trend: trend(0, [12, 12, 12, 12, 12, 12, 12]), + maintainers: [], + contributors: [], + participants: [], + commits1y: 0, + changeDriver: { label: 'Newly added', direction: 'flat' }, + }; + return { ...base, description: `${p.name} was recently added to this workspace.`, healthMetrics: buildHealthMetrics(base) }; + }); +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts index 44a08b079..acc3049e9 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts @@ -4,10 +4,10 @@ // Generated with [Claude Code](https://claude.ai/code) import { Injectable } from '@angular/core'; -import type { OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; +import type { OrgLensProject, OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; import { delay, Observable, of } from 'rxjs'; -import { getDemoProjectsResponse } from './org-lens-projects.demo-data'; +import { buildAddedProjects, getAddableProjectOptions, getDemoProjectsResponse } from './org-lens-projects.demo-data'; /** Simulated network latency so the page exercises its loading skeletons. */ const DEMO_LATENCY_MS = 450; @@ -27,4 +27,14 @@ export class OrgLensProjectsService { public getProjects(orgUid: string, orgName: string): Observable { return of(getDemoProjectsResponse(orgUid, orgName)).pipe(delay(DEMO_LATENCY_MS)); } + + /** Catalog of projects that can be added to a workspace (`{ value, label, logoUrl }` for the multi-select). */ + public getAddableProjectOptions(): { value: string; label: string; logoUrl: string }[] { + return getAddableProjectOptions(); + } + + /** Build full project rows for the given catalog slugs (used when the user adds projects to a workspace). */ + public buildAddedProjects(slugs: readonly string[]): OrgLensProject[] { + return buildAddedProjects(slugs); + } } diff --git a/apps/lfx-one/src/styles.scss b/apps/lfx-one/src/styles.scss index 52bd155dd..850ee6949 100644 --- a/apps/lfx-one/src/styles.scss +++ b/apps/lfx-one/src/styles.scss @@ -361,12 +361,19 @@ html { } } -// Org Lens projects — explanatory column tooltips need more width than PrimeNG's default 12.5rem cap. +// Org Lens projects — explanatory column tooltips need more width than the default. // Tooltips are appended to , so they must be styled globally (component/Tailwind-scoped styles don't reach them). -// Higher specificity (`.p-tooltip.`) + !important so these win over PrimeNG's default 12.5rem cap -// regardless of stylesheet order. +// PrimeNG sets `width: fit-content` inline on the tooltip container, which can shrink it below the text max-width; +// a `min-width` floor on the container (min-width coexists with the inline width) forces a readable width, and +// !important wins over both PrimeNG's layered theme and its inline style. +.p-tooltip.lfx-tooltip-wide { + min-width: 26rem !important; + max-width: 40rem !important; +} + .p-tooltip.lfx-tooltip-wide .p-tooltip-text { - max-width: 36rem !important; + max-width: 40rem !important; + white-space: normal !important; } .p-tooltip.lfx-tooltip-nowrap .p-tooltip-text { From 27a934596f96d73e69b4df3434694162415e0767 Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 11 Jun 2026 09:45:58 -0600 Subject: [PATCH 09/12] fix(review): harden csv export, employee filter, and view-model typing - Ignore stale/unknown employee ids from the URL before filtering so a shared deep link can't filter out every project (CodeRabbit). - downloadCsv: guard on the full Blob-URL API (not just document) and sanitize the filename via sanitizeFilename (Copilot). - Clearly mark OrgProjectsSignalBar/OrgProjectsTableRow as client-only view models (not API wire contracts); note trendTooltipHtml must never come from the wire (Copilot). LFXV2-1883 Signed-off-by: daniel qualls --- .../org/org-projects/org-projects.component.ts | 4 +++- .../interfaces/org-lens-projects.interface.ts | 16 ++++++++++++---- packages/shared/src/utils/file.utils.ts | 5 +++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index 35d64b567..e5ead994e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -480,7 +480,9 @@ export class OrgProjectsComponent { const all = [...(this.response()?.projects ?? []), ...this.addedProjects()]; const workspace = this.selectedWorkspaceId(); const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; - const employees = this.formValue().employees?.filter(Boolean) ?? []; + // Drop stale/unknown employee ids from the URL so a shared deep link can't filter everything out. + const validEmployeeIds = new Set(this.employeeOptions().map((option) => option.value)); + const employees = (this.formValue().employees ?? []).filter((id) => validEmployeeIds.has(id)); const hidden = this.hiddenSlugs(); return all .filter((p) => !hidden.has(p.slug)) diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts index 146ae9641..603e4774f 100644 --- a/packages/shared/src/interfaces/org-lens-projects.interface.ts +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -4,10 +4,11 @@ /** * Org Lens — Projects page contracts (LFXV2-1883 / LFXV2-1884). * - * These are the real API contracts the Projects page renders against. The current - * implementation is fed by a demo-data fixture through `OrgLensProjectsService`; the - * live Snowflake / LFX Insights integration (a separate story) will populate the same - * shapes without any component changes. + * The wire contracts (OrgLensProject, OrgLensProjectsResponse, etc.) are what the API returns; + * the current implementation is fed by a demo-data fixture through `OrgLensProjectsService`, and the + * live Snowflake / LFX Insights integration (a separate story) will populate the same shapes without + * any component changes. The "Client-only view models" section at the bottom is NOT on the wire — + * it is built in the component and must never be populated from API data. */ /** Influence band per the markup-mu methodology (Boysel et al.). Declared strongest → weakest. */ @@ -134,6 +135,13 @@ export type OrgProjectsSortField = 'name' | 'health' | 'technicalInfluence' | 'e /** Sort direction (`?dir=`). */ export type SortDirection = 'asc' | 'desc'; +/* + * ── Client-only view models (NOT API wire contracts) ───────────────────────── + * Built in the component off the wire types above; never populated from API data. + * `trendTooltipHtml` is component-authored markup rendered with `[escape]="false"`, + * so it must never be sourced from the wire — keep these types out of any API mapping. + */ + /** A single rounded bar in the influence signal-strength icon (precomputed for the table view-model). */ export interface OrgProjectsSignalBar { x: number; diff --git a/packages/shared/src/utils/file.utils.ts b/packages/shared/src/utils/file.utils.ts index 631cf88d5..93b594725 100644 --- a/packages/shared/src/utils/file.utils.ts +++ b/packages/shared/src/utils/file.utils.ts @@ -311,7 +311,8 @@ export function rowsToCsv(rows: ReadonlyArray>): * @param rows - Array of rows, each an array of cell values */ export function downloadCsv(filename: string, rows: ReadonlyArray>): void { - if (typeof document === 'undefined') { + // Browser-only: bail unless the full DOM + Blob URL APIs exist (a partial SSR DOM may lack createObjectURL). + if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { return; } @@ -319,7 +320,7 @@ export function downloadCsv(filename: string, rows: ReadonlyArray Date: Thu, 11 Jun 2026 09:51:10 -0600 Subject: [PATCH 10/12] fix(review): scope add/hide state per workspace; clear workspace URL on delete - Key the added-projects and hidden-slugs collections by workspace id so hiding or adding a project in one workspace no longer bleeds into the others (CodeRabbit). - deleteWorkspace now clears/replaces the ?workspace= param when the deleted workspace was active, instead of leaving a stale id in the URL (CodeRabbit). LFXV2-1883 Signed-off-by: daniel qualls --- .../org-projects/org-projects.component.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index e5ead994e..85e17e21e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -122,8 +122,8 @@ export class OrgProjectsComponent { // Writable Signals protected readonly loading = signal(false); protected readonly error = signal(false); - /** Slugs hidden from the current workspace (client-only; never mutates the foundation catalog). */ - protected readonly hiddenSlugs = signal>(new Set()); + /** Hidden project slugs keyed by workspace id — hide is workspace-local (client-only; never mutates the catalog). */ + protected readonly hiddenByWorkspace = signal>>({}); /** Shared workspaces (seeded presets + user-created); editable via the workspace dropdown. */ protected readonly workspaces = signal([...DEFAULT_ORG_PROJECTS_WORKSPACES]); /** Workspace being renamed/deleted in the settings dialog; `null` while the dialog adds a new one. */ @@ -132,8 +132,8 @@ export class OrgProjectsComponent { protected readonly workspaceDialogOpen = model(false); /** Two-way visibility for the "Add project(s)" dialog (`[(visible)]`). */ protected readonly addProjectsDialogOpen = model(false); - /** Projects added to the current workspace via the Add Project dialog (client-only demo state). */ - protected readonly addedProjects = signal([]); + /** Projects added via the Add Project dialog, keyed by workspace id — add is workspace-local (client-only demo state). */ + protected readonly addedByWorkspace = signal>({}); /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ private readonly reload = signal(0); /** Action menu items rebuilt per row when the kebab is opened. */ @@ -256,10 +256,11 @@ export class OrgProjectsComponent { protected confirmAddProjects(): void { const slugs = this.addProjectsForm.getRawValue().projects; - const existing = new Set([...(this.response()?.projects ?? []), ...this.addedProjects()].map((p) => p.slug)); + const ws = this.selectedWorkspaceId(); + const existing = new Set([...(this.response()?.projects ?? []), ...(this.addedByWorkspace()[ws] ?? [])].map((p) => p.slug)); const additions = this.projectsService.buildAddedProjects(slugs).filter((p) => !existing.has(p.slug)); if (additions.length) { - this.addedProjects.update((prev) => [...prev, ...additions]); + this.addedByWorkspace.update((map) => ({ ...map, [ws]: [...(map[ws] ?? []), ...additions] })); } this.addProjectsDialogOpen.set(false); } @@ -306,11 +307,13 @@ export class OrgProjectsComponent { if (!editing) { return; } + // Capture before mutating: once removed, selectedWorkspaceId() already falls back, hiding the stale `?workspace=`. + const wasActive = this.selectedWorkspaceId() === editing.id; // Re-seed the default if the last workspace is removed so the company is never left with none. const remaining = this.workspaces().filter((w) => w.id !== editing.id); const next = remaining.length > 0 ? remaining : [...DEFAULT_ORG_PROJECTS_WORKSPACES]; this.workspaces.set(next); - if (!next.some((w) => w.id === this.selectedWorkspaceId())) { + if (wasActive) { this.selectWorkspace(next[0].id); } this.workspaceDialogOpen.set(false); @@ -477,13 +480,13 @@ export class OrgProjectsComponent { private initFilteredProjects(): Signal { return computed(() => { - const all = [...(this.response()?.projects ?? []), ...this.addedProjects()]; const workspace = this.selectedWorkspaceId(); + const all = [...(this.response()?.projects ?? []), ...(this.addedByWorkspace()[workspace] ?? [])]; const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; // Drop stale/unknown employee ids from the URL so a shared deep link can't filter everything out. const validEmployeeIds = new Set(this.employeeOptions().map((option) => option.value)); const employees = (this.formValue().employees ?? []).filter((id) => validEmployeeIds.has(id)); - const hidden = this.hiddenSlugs(); + const hidden = this.hiddenByWorkspace()[workspace] ?? new Set(); return all .filter((p) => !hidden.has(p.slug)) .filter((p) => this.matchesWorkspace(p, workspace)) @@ -578,7 +581,8 @@ export class OrgProjectsComponent { } private hideFromWorkspace(slug: string): void { - this.hiddenSlugs.update((set) => new Set(set).add(slug)); + const ws = this.selectedWorkspaceId(); + this.hiddenByWorkspace.update((map) => ({ ...map, [ws]: new Set(map[ws] ?? []).add(slug) })); } private uniqueWorkspaceId(name: string): string { From 6368ff5f1f435e3cd49f811546275bd8440133da Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 11 Jun 2026 09:59:12 -0600 Subject: [PATCH 11/12] fix(review): keep numeric csv cells numeric; expose health detail via aria-label - escapeCsvCell only prefixes the formula-injection guard for string cells, so numeric columns (e.g. trend deltas, negative values) stay numeric in spreadsheets (Copilot). - Health cell exposes the full rating + sub-scores via aria-label (role=img, focusable) so keyboard/screen-reader users get the popover content textually; removed the focus-open/blur-hide that flashed an unreachable popover (Copilot). LFXV2-1883 Signed-off-by: daniel qualls --- .../org/org-projects/org-projects.component.html | 14 +++++++------- .../org/org-projects/org-projects.component.ts | 6 ++++++ .../src/interfaces/org-lens-projects.interface.ts | 2 ++ packages/shared/src/utils/file.utils.ts | 3 ++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html index 3bbe31aa3..286a5f0f5 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -216,19 +216,19 @@

    {{ project.name }} - + - + diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts index 85e17e21e..6496d11a0 100644 --- a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -416,6 +416,11 @@ export class OrgProjectsComponent { const fmt = (v: number): string => `${v > 0 ? '+' : ''}${v}%`; return `Influence trend over the past year — combined ${fmt(t.deltaPct)}, technical ${fmt(t.technicalDeltaPct)}, ecosystem ${fmt(t.ecosystemDeltaPct)}.`; } + // Full health summary (rating + sub-scores) so keyboard/screen-reader users get the popover's content without a mouse. + protected healthAriaLabel(project: OrgLensProject): string { + const metrics = project.healthMetrics.map((m) => `${m.label} ${m.value}`).join(', '); + return `Health: ${HEALTH_SCORE_LABELS[project.health]}. ${metrics}.`; + } protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { const cached = this.sparklineCache.get(project); if (cached) { @@ -514,6 +519,7 @@ export class OrgProjectsComponent { ecosystemBars: this.bandBars(project.ecosystemInfluence), trendTooltipHtml: this.trendTooltip(project), trendAriaLabel: this.trendAriaLabel(project), + healthAriaLabel: this.healthAriaLabel(project), })) ); } diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts index 603e4774f..3e96ec9d1 100644 --- a/packages/shared/src/interfaces/org-lens-projects.interface.ts +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -160,4 +160,6 @@ export interface OrgProjectsTableRow extends OrgLensProject { trendTooltipHtml: string; /** Plain-text trend summary for screen readers / keyboard focus. */ trendAriaLabel: string; + /** Plain-text health summary (rating + sub-scores) for screen readers / keyboard focus. */ + healthAriaLabel: string; } diff --git a/packages/shared/src/utils/file.utils.ts b/packages/shared/src/utils/file.utils.ts index 93b594725..0a05456cd 100644 --- a/packages/shared/src/utils/file.utils.ts +++ b/packages/shared/src/utils/file.utils.ts @@ -291,7 +291,8 @@ export function sanitizeFilename(filename: string, maxLength: number = 255): str */ function escapeCsvCell(value: string | number): string { const raw = String(value ?? ''); - const safe = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + // Only string cells can carry a formula payload; leave numbers numeric (prefixing makes Excel treat them as text). + const safe = typeof value === 'string' && /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; } From 3cc1c1ffc491d388f2cf79dd5a6d05256853c87a Mon Sep 17 00:00:00 2001 From: daniel qualls Date: Thu, 11 Jun 2026 10:07:17 -0600 Subject: [PATCH 12/12] fix(review): type demo health-base map against the HealthScore union Type baseByHealth as Record so missing/misspelled keys are caught at compile time (Copilot). LFXV2-1883 Signed-off-by: daniel qualls --- .../src/app/shared/services/org-lens-projects.demo-data.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts index 3ff2cf717..e906d1739 100644 --- a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -4,6 +4,7 @@ // Generated with [Claude Code](https://claude.ai/code) import type { + HealthScore, InfluenceTrend, InfluenceTrendDirection, OrgLensProject, @@ -352,8 +353,8 @@ const DESCRIPTION_BY_SLUG: Record = { // Deterministic CHAOSS-style sub-scores so the health popover is stable across reloads. Real data supplies these. function buildHealthMetrics(project: Omit): ProjectHealthMetric[] { - const baseByHealth: Record = { excellent: 84, healthy: 64, 'at-risk': 42 }; - const base = baseByHealth[project.health] ?? 60; + const baseByHealth: Record = { excellent: 84, healthy: 64, 'at-risk': 42 }; + const base = baseByHealth[project.health]; const seed = project.slug.length + Math.round(project.influenceScore); const score = (offset: number): number => Math.max(22, Math.min(98, base + ((seed * (offset + 3)) % 26) - 10)); return [