diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts
index 8df682a26..f1f272e94 100644
--- a/apps/lfx-one/src/app/app.routes.ts
+++ b/apps/lfx-one/src/app/app.routes.ts
@@ -50,11 +50,91 @@ export const routes: Routes = [
data: { lens: 'project' },
loadComponent: () => import('./modules/dashboards/dashboard.component').then((m) => m.DashboardComponent),
},
- // Org Lens dashboard (placeholder — reuses DashboardComponent for now)
{
- path: 'org',
+ path: 'org/overview',
data: { lens: 'org' },
- loadComponent: () => import('./modules/dashboards/dashboard.component').then((m) => m.DashboardComponent),
+ loadComponent: () => import('./modules/dashboards/org/org-overview/org-overview.component').then((m) => m.OrgOverviewComponent),
+ },
+ {
+ path: 'org/memberships',
+ data: { lens: 'org', title: 'Memberships', description: 'Active memberships and tier history.', icon: 'fa-light fa-display' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/projects',
+ data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/roi',
+ data: { lens: 'org', title: 'ROI', description: 'Return on investment across your memberships and engagement.', icon: 'fa-light fa-chart-line-up' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/governance',
+ data: { lens: 'org', title: 'Governance', description: 'Board seats and governance participation.', icon: 'fa-light fa-layer-group' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/people',
+ data: { lens: 'org', title: 'People', description: 'Employees and contributors associated with your organization.', icon: 'fa-light fa-users' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/contributions',
+ data: {
+ lens: 'org',
+ title: 'Code Contributions',
+ description: "Open-source contributions from your organization's contributors.",
+ icon: 'fa-light fa-code',
+ },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/events',
+ data: { lens: 'org', title: 'Events', description: 'Events your organization is sponsoring or attending.', icon: 'fa-light fa-calendar' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/training',
+ data: {
+ lens: 'org',
+ title: 'Training & Certification',
+ description: 'Training enrollments and certifications across your organization.',
+ icon: 'fa-light fa-graduation-cap',
+ },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/meetings',
+ data: { lens: 'org', title: 'Meetings', description: 'Meetings your organization is participating in.', icon: 'fa-light fa-video' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/groups',
+ data: { lens: 'org', title: 'Groups', description: 'Committees your organization participates in.', icon: 'fa-light fa-users-rectangle' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org/profile',
+ data: { lens: 'org', title: 'Profile', description: 'Public-facing details about your organization.', icon: 'fa-light fa-file' },
+ loadComponent: () =>
+ import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent),
+ },
+ {
+ path: 'org',
+ redirectTo: 'org/overview',
+ pathMatch: 'full',
},
// Foundation Lens — feature routes (lens-tagged so deep links restore the foundation lens)
{
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html
index 5e25090de..7624ed909 100644
--- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html
+++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html
@@ -33,6 +33,7 @@
[items]="sidebarItems()"
[showMeSelector]="activeLens() === 'me'"
[showProjectSelector]="activeLens() === 'project' || activeLens() === 'foundation'"
+ [showOrgSelector]="activeLens() === 'org'"
[(selectorPanelOpen)]="selectorPanelOpen">
@@ -64,6 +65,7 @@
[items]="sidebarItems()"
[showMeSelector]="activeLens() === 'me'"
[showProjectSelector]="activeLens() === 'project' || activeLens() === 'foundation'"
+ [showOrgSelector]="activeLens() === 'org'"
[(selectorPanelOpen)]="selectorPanelOpen"
[mobile]="true">
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
index f8b84b0c7..b8555e54d 100644
--- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
+++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
@@ -335,70 +335,84 @@ export class MainLayoutComponent {
},
];
- // --- Org Lens Items ---
private readonly orgLensItems: SidebarMenuItem[] = [
{
- label: 'Overview',
+ label: 'Org Overview',
icon: 'fa-light fa-grid-2',
- routerLink: '/org',
+ routerLink: '/org/overview',
},
{
- label: 'Portfolio',
+ label: 'Org Foundations',
isSection: true,
expanded: true,
items: [
{
- label: 'Key Projects',
- icon: 'fa-light fa-diagram-project',
+ label: 'Memberships',
+ icon: 'fa-light fa-display',
+ routerLink: '/org/memberships',
+ },
+ {
+ label: 'Projects',
+ icon: 'fa-light fa-folder',
routerLink: '/org/projects',
},
{
- label: 'Code Contributions',
- icon: 'fa-light fa-code',
- routerLink: '/org/code',
+ label: 'ROI',
+ icon: 'fa-light fa-chart-line-up',
+ routerLink: '/org/roi',
+ },
+ {
+ label: 'Governance',
+ icon: 'fa-light fa-layer-group',
+ routerLink: '/org/governance',
},
],
},
{
- label: 'Membership',
+ label: 'Org Engagement',
isSection: true,
expanded: true,
items: [
{
- label: 'Membership',
- icon: 'fa-light fa-id-card',
- routerLink: '/org/membership',
+ label: 'People',
+ icon: 'fa-light fa-users',
+ routerLink: '/org/people',
},
{
- label: 'Benefits',
- icon: 'fa-light fa-gift',
- routerLink: '/org/benefits',
+ label: 'Code Contributions',
+ icon: 'fa-light fa-code',
+ routerLink: '/org/contributions',
},
- ],
- },
- {
- label: 'Administration',
- isSection: true,
- expanded: true,
- items: [
{
- label: COMMITTEE_LABEL.plural,
- icon: 'fa-light fa-users-rectangle',
- routerLink: '/org/groups',
+ label: 'Events',
+ icon: 'fa-light fa-calendar',
+ routerLink: '/org/events',
},
{
- label: 'CLA Management',
- icon: 'fa-light fa-file-signature',
- routerLink: '/org/cla',
+ label: 'Training & Certification',
+ icon: 'fa-light fa-graduation-cap',
+ routerLink: '/org/training',
},
{
- label: 'Access & Permissions',
- icon: 'fa-light fa-key',
- routerLink: '/org/permissions',
+ label: 'Meetings',
+ icon: 'fa-light fa-video',
+ routerLink: '/org/meetings',
},
{
- label: 'Org Profile',
- icon: 'fa-light fa-building',
+ label: COMMITTEE_LABEL.plural,
+ icon: 'fa-light fa-users-rectangle',
+ routerLink: '/org/groups',
+ },
+ ],
+ },
+ {
+ label: 'Org Admin',
+ isSection: true,
+ expanded: true,
+ items: [
+ {
+ label: 'Profile',
+ icon: 'fa-light fa-file',
routerLink: '/org/profile',
},
],
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html
new file mode 100644
index 000000000..caf8fea9a
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+ @if (row().foundationLogoUrl) {
+
+ } @else {
+
+ {{ initials() }}
+
+ }
+
+
+
{{ row().foundationName }}
+
+ @if (showProjectsInvolved()) {
+
{{ projectsInvolvedText() }} ·
+ }
+
+
+
+
+ {{ subtitleText() }}
+
+
+
+
+
+
+
+
+ {{ row().badges.orgRole }}
+
+
+
+
+ @if (row().badges.votingStatus === '—') {
+ —
+ } @else {
+
+ {{ row().badges.votingStatus }}
+
+ }
+
+
+
+ @if (row().badges.governanceParticipation === '—') {
+ —
+ } @else {
+
+ {{ row().badges.governanceParticipation }}
+
+ }
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss
new file mode 100644
index 000000000..48bd97bfa
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss
@@ -0,0 +1,11 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+// Host element disappears from the box tree so the inner `` is a direct
+// child of the parent ` ` (required for HTML table layout). This is
+// the canonical Angular alternative to attribute-selector row components,
+// which our eslint rules disallow (component selectors must be kebab-case
+// elements per `@angular-eslint/component-selector`).
+:host {
+ display: contents;
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts
new file mode 100644
index 000000000..2c1246de1
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts
@@ -0,0 +1,178 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, inject, input, output } from '@angular/core';
+import { Router } from '@angular/router';
+import { TooltipModule } from 'primeng/tooltip';
+
+import type { GovernanceParticipationBucket, OrgLensFoundationRow, OrgRoleBadge, VotingStatusBadge } from '@lfx-one/shared/interfaces';
+
+import { foundationInitials, foundationLogoSquareClasses } from '../helpers/foundation-logo.helper';
+import { tierRibbonClasses } from '../helpers/tier-ribbon.helper';
+
+/**
+ * Presentational foundation row. Renders the four-cell main `` inside
+ * its template: Name (logo + name + tier-ribbon subtitle + chevron),
+ * Org Role, Voting Status, Governance Participation.
+ *
+ * Hosting strategy: the component declares `:host { display: contents }` in
+ * SCSS so the `` element disappears from the box tree
+ * and the inner `` becomes a direct child of ` ` — required for
+ * HTML table layout. This is the Angular-canonical alternative to
+ * attribute-selector row components, which our `@angular-eslint/component-selector`
+ * rule disallows (must be kebab-case element selector).
+ *
+ * Inline-detail (expansion) data is rendered by the parent component in a
+ * sibling `` because expansion can grow/shrink independently of the
+ * presentational row.
+ *
+ * Behaviour-level concerns owned by this component:
+ * - Row-body click navigates to /org/memberships (no slug parameter — the
+ * slug-aware Memberships detail page is a follow-on feature; the
+ * current Memberships route is a placeholder page) for member /
+ * non_member rows; outside_lf is a no-op. When the slug-aware page
+ * lands, restore the slug argument here AND register
+ * `path: 'org/memberships/:foundationSlug'` in app.routes.ts.
+ * - Chevron click toggles expansion via the `toggle` output and stops
+ * propagation so the row-body click does NOT also fire.
+ * - Keyboard activation (Enter / Space) mirrors the click handler.
+ */
+@Component({
+ selector: 'lfx-foundation-row',
+ imports: [TooltipModule],
+ templateUrl: './foundation-row.component.html',
+ styleUrls: ['./foundation-row.component.scss'],
+})
+export class FoundationRowComponent {
+ public readonly row = input.required();
+ public readonly expanded = input(false);
+ public readonly index = input(0);
+
+ public readonly toggle = output();
+ public readonly rowClick = output<{ foundationName: string; isMember: boolean }>();
+ public readonly caretToggle = output<{ foundationName: string; expanded: boolean }>();
+
+ private readonly router = inject(Router);
+
+ protected readonly logoSquareClasses = computed(() => foundationLogoSquareClasses(this.row().foundationId));
+ protected readonly initials = computed(() => foundationInitials(this.row().foundationName));
+ protected readonly ribbonClasses = computed(() => tierRibbonClasses(this.row().membershipTierClass, this.row().rowKind));
+
+ /**
+ * Subtitle pill text. Binds to the canonical `membershipTierClass`
+ * (NOT `membershipTierDisplayName`) so the badge renders the canonical
+ * tier label.
+ */
+ protected readonly subtitleText = computed(() => {
+ const r = this.row();
+ if (r.rowKind === 'outside_lf') return 'Outside LF';
+ if (r.rowKind === 'non_member') return 'Non-member';
+ const tier = r.membershipTierClass ?? 'Member';
+ return `${tier} Member`;
+ });
+
+ protected readonly projectsInvolvedText = computed(() => {
+ const count = this.row().projectCount;
+ return `${count} project${count === 1 ? '' : 's'} involved`;
+ });
+
+ /**
+ * The "N projects involved · " prefix renders on every row regardless of
+ * `rowKind` (member / non_member / outside_lf). The visual design shows
+ * the prefix for non-member and Outside-LF rows too (e.g. "8 projects
+ * involved · Outside LF"), so the gate is kept as a signal but always
+ * resolves to true. Left as a computed so it can be tightened later
+ * without touching the template.
+ */
+ protected readonly showProjectsInvolved = computed(() => true);
+
+ protected readonly chevronAriaLabel = computed(() => {
+ const action = this.expanded() ? 'Collapse' : 'Expand';
+ return `${action} ${this.row().foundationName}`;
+ });
+
+ protected readonly governanceTooltip = computed(() => {
+ const pct = this.row().badges.governanceAttendancePct;
+ if (pct == null) return '';
+ const pctInt = Math.round(pct * 100);
+ return `${pctInt}% meeting attendance across governance, working groups, and project meetings`;
+ });
+
+ protected readonly governanceHeaderTooltip =
+ 'Average meeting attendance across governance, working groups, and project meetings. <33% Inactive · 33–66% Partial · >66% Active.';
+
+ protected readonly testIdSlug = computed(() => this.row().foundationSlug || this.row().foundationId);
+
+ protected readonly trRole = computed(() => (this.row().rowKind === 'outside_lf' ? null : 'button'));
+ protected readonly trTabIndex = computed(() => (this.row().rowKind === 'outside_lf' ? null : 0));
+ protected readonly trClasses = computed(() => {
+ const base = 'border-b border-gray-200 hover:bg-gray-50 transition-colors';
+ return this.row().rowKind === 'outside_lf' ? `${base} cursor-default` : `${base} cursor-pointer`;
+ });
+
+ public onRowClick(event: MouseEvent): void {
+ // Defensive: if the click landed inside a real interactive child
+ // (chevron button, future inline link, etc.), let that child handle
+ // it instead of firing the row navigation. We deliberately do NOT
+ // match `[role="button"]` here because the row's own carries
+ // `role="button"` and a `closest()` query would always match the row
+ // itself — silently swallowing every row click. The chevron button
+ // already calls `event.stopPropagation()`, so this branch is mainly
+ // a safety net for future inner affordances.
+ const target = event.target as HTMLElement | null;
+ if (target && target.closest('button, a')) {
+ return;
+ }
+ const r = this.row();
+ if (r.rowKind === 'outside_lf') {
+ return;
+ }
+ this.rowClick.emit({ foundationName: r.foundationName, isMember: r.rowKind === 'member' });
+ void this.router.navigate(['/org/memberships']);
+ }
+
+ public onRowKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ const r = this.row();
+ if (r.rowKind === 'outside_lf') return;
+ event.preventDefault();
+ this.rowClick.emit({ foundationName: r.foundationName, isMember: r.rowKind === 'member' });
+ void this.router.navigate(['/org/memberships']);
+ }
+
+ public onChevronClick(event: Event): void {
+ event.stopPropagation();
+ const nextExpanded = !this.expanded();
+ this.toggle.emit();
+ this.caretToggle.emit({ foundationName: this.row().foundationName, expanded: nextExpanded });
+ }
+
+ /** Org Role badge palette via Tailwind utilities → lfxColors. */
+ protected orgRoleBadgeClasses(badge: OrgRoleBadge): string {
+ if (badge === 'Director' || badge === 'Member') {
+ return 'bg-blue-100 text-blue-700 border-blue-200';
+ }
+ return 'bg-white text-gray-600 border-gray-300';
+ }
+
+ protected votingStatusBadgeClasses(badge: VotingStatusBadge): string {
+ if (badge === 'Voting' || badge === 'Observer') {
+ return 'bg-blue-100 text-blue-700 border-blue-200';
+ }
+ return 'text-gray-400';
+ }
+
+ protected governanceBadgeClasses(bucket: GovernanceParticipationBucket): string {
+ switch (bucket) {
+ case 'Active':
+ return 'bg-emerald-100 text-emerald-700';
+ case 'Partial':
+ return 'bg-amber-100 text-amber-700';
+ case 'Inactive':
+ return 'bg-gray-100 text-gray-500';
+ case '—':
+ default:
+ return 'text-gray-400';
+ }
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html
new file mode 100644
index 000000000..9b7b1d6ea
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ foundationsTotal() }}
+
Foundations
+
{{ foundationsDetail() }}
+
+
+
+
+
+
+
+
+
+
{{ projectsTotal() }}
+
Projects
+
{{ projectsDetail() }}
+
+
+
+
+
+
+
+
+
+
{{ governanceTotal() }}
+
Governance Roles
+
{{ governanceDetail() }}
+
+
+
+
+
+
+
+
+
+
{{ meetingsTotal() }}
+
Meetings This Week
+
{{ meetingsDetail() }}
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts
new file mode 100644
index 000000000..c68c219ba
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts
@@ -0,0 +1,117 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, input } from '@angular/core';
+
+import type { MembershipTierClass, OrgLensFoundationsStatStrip } from '@lfx-one/shared/interfaces';
+
+/**
+ * Canonical 13-class tier order. Used to order the foundations tile's
+ * detail line. Zero-bucket entries are suppressed at render time.
+ */
+const TIER_ORDER: MembershipTierClass[] = [
+ 'Platinum',
+ 'Premier',
+ 'Founding',
+ 'Strategic',
+ 'Gold',
+ 'Steering',
+ 'Silver',
+ 'General',
+ 'Associate',
+ 'End User',
+ 'Academic',
+ 'Contributor',
+ 'Other',
+];
+
+/**
+ * 4-tile stat strip presented above the foundations table:
+ * - Foundations
+ * - Projects (Leading / Contributing / Participating / Silent)
+ * - Governance Roles (Board members / Committee members)
+ * - Meetings This Week (Board / Technical / Marketing / WG / Other)
+ *
+ * Every colour resolves through `lfxColors`. Zero-bucket entries are
+ * suppressed. When a tile's total is zero, the big number still renders
+ * as `0` (so users see the truthful count, not a "data missing" hint);
+ * only the detail line collapses to a single em-dash placeholder.
+ */
+@Component({
+ selector: 'lfx-foundations-stat-strip',
+ imports: [],
+ templateUrl: './foundations-stat-strip.component.html',
+})
+export class FoundationsStatStripComponent {
+ public readonly strip = input.required();
+
+ /**
+ * Detail-line placeholder. Any empty `parts` array — every sub-bucket
+ * of the tile is zero — collapses to a single em-dash so the tile
+ * never renders a list of zeros.
+ */
+ private static readonly emptyDetail = '—';
+
+ /**
+ * Foundations tile — ordered by the canonical 13-class ladder, zero
+ * suppressed. Design format: "Platinum (2), Gold (1), …".
+ */
+ protected readonly foundationsDetail = computed(() => {
+ const breakdown = this.strip().foundations.breakdown;
+ const parts: string[] = [];
+ for (const tier of TIER_ORDER) {
+ const count = breakdown[tier];
+ if (count && count > 0) {
+ parts.push(`${tier} (${count})`);
+ }
+ }
+ return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail;
+ });
+
+ /**
+ * Projects tile — Leading / Contributing / Participating / Silent.
+ * Design format: "Leading (3), Contributing (1), …".
+ */
+ protected readonly projectsDetail = computed(() => {
+ const p = this.strip().projects;
+ const parts: string[] = [];
+ if (p.leading > 0) parts.push(`Leading (${p.leading})`);
+ if (p.contributing > 0) parts.push(`Contributing (${p.contributing})`);
+ if (p.participating > 0) parts.push(`Participating (${p.participating})`);
+ if (p.silent > 0) parts.push(`Silent (${p.silent})`);
+ return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail;
+ });
+
+ /**
+ * Governance roles tile — design format:
+ * "Board members (B) Committee members (C)" (space-separated, plural
+ * always, matching the `mfp-governance-sub` renderer in the design).
+ */
+ protected readonly governanceDetail = computed(() => {
+ const g = this.strip().governanceRoles;
+ const parts: string[] = [];
+ if (g.boardMembers > 0) parts.push(`Board members (${g.boardMembers})`);
+ if (g.committeeMembers > 0) parts.push(`Committee members (${g.committeeMembers})`);
+ return parts.length > 0 ? parts.join('\u00A0\u00A0\u00A0') : FoundationsStatStripComponent.emptyDetail;
+ });
+
+ /**
+ * Meetings this week tile — 5-way category split. Design format:
+ * "Board (3), Technical (6), Working Group (10), Other (5)".
+ */
+ protected readonly meetingsDetail = computed(() => {
+ const m = this.strip().meetingsThisWeek;
+ const parts: string[] = [];
+ if (m.board > 0) parts.push(`Board (${m.board})`);
+ if (m.technical > 0) parts.push(`Technical (${m.technical})`);
+ if (m.marketing > 0) parts.push(`Marketing (${m.marketing})`);
+ if (m.workingGroup > 0) parts.push(`Working Group (${m.workingGroup})`);
+ if (m.other > 0) parts.push(`Other (${m.other})`);
+ return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail;
+ });
+
+ protected readonly foundationsTotal = computed(() => this.strip().foundations.total);
+ protected readonly projectsTotal = computed(() => this.strip().projects.total);
+ protected readonly governanceTotal = computed(() => this.strip().governanceRoles.total);
+ protected readonly meetingsTotal = computed(() => this.strip().meetingsThisWeek.total);
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts
new file mode 100644
index 000000000..9a47d9629
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts
@@ -0,0 +1,54 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+/**
+ * Foundation-logo letter-square fallback palette.
+ *
+ * Deterministic `djb2(foundation_id) % 5` map into the chromatic LFX
+ * families [blue, emerald, amber, red, violet]. Excludes `gray` so the
+ * letter square never blends into the Outside-LF umbrella row's gray
+ * pill. Same `foundation_id` always maps to the same colour across
+ * renders / sort changes / org switches — stable identity cue, not a
+ * row-position cue.
+ *
+ * Each family renders as `bg-{family}-600 text-white` for high-contrast
+ * initials. Class strings are full literals so Tailwind JIT picks them
+ * up from source.
+ */
+
+const SQUARE_PALETTE = ['bg-blue-600 text-white', 'bg-emerald-600 text-white', 'bg-amber-600 text-white', 'bg-red-600 text-white', 'bg-violet-600 text-white'];
+
+/** Classic djb2 string hash (Daniel J. Bernstein). Returns unsigned 32-bit. */
+function djb2(input: string): number {
+ let hash = 5381;
+ for (let i = 0; i < input.length; i += 1) {
+ // hash * 33 ^ char
+ hash = ((hash << 5) + hash) ^ input.charCodeAt(i);
+ }
+ return hash >>> 0;
+}
+
+export function foundationLogoSquareClasses(foundationId: string): string {
+ const index = djb2(foundationId) % SQUARE_PALETTE.length;
+ return SQUARE_PALETTE[index];
+}
+
+/**
+ * Foundation initials. First letter of each capital-led word, max 2 chars.
+ * Falls back to the first 2 characters of the name uppercased if the
+ * name has no capital-led words (e.g., lowercase slug fallback).
+ */
+export function foundationInitials(name: string): string {
+ if (!name) return '?';
+
+ const capitalWords = name.match(/[A-Z][A-Za-z0-9]*/g);
+ if (capitalWords && capitalWords.length > 0) {
+ const initials = capitalWords
+ .slice(0, 2)
+ .map((word) => word.charAt(0))
+ .join('');
+ return initials.toUpperCase();
+ }
+
+ return name.slice(0, 2).toUpperCase();
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts
new file mode 100644
index 000000000..a2225c316
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts
@@ -0,0 +1,50 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import type { MembershipTierClass, OrgLensRowKind } from '@lfx-one/shared/interfaces';
+
+/**
+ * Tier-ribbon pill palette.
+ *
+ * Rank-banded mapping into 4 LFX semantic families:
+ * - Top-4 (premium): Platinum / Premier / Founding / Strategic → violet
+ * - Mid (sponsor): Gold / Steering / Silver → amber
+ * - Standard: General / Associate → blue
+ * - Free / observer: End User / Academic / Contributor / Other → gray
+ * - Non-member LF row: → amber outline
+ * - Outside LF row: → gray
+ *
+ * Every class string resolves through `lfxColors` via the Tailwind
+ * theme extension (apps/lfx-one/tailwind.config.js wires `theme.extend
+ * .colors = lfxColors`). NO raw hex; NO non-LFX palettes.
+ */
+export function tierRibbonClasses(tierClass: MembershipTierClass | null | undefined, rowKind: OrgLensRowKind): string {
+ if (rowKind === 'outside_lf') {
+ return 'bg-gray-100 text-gray-600';
+ }
+
+ if (rowKind === 'non_member') {
+ return 'bg-amber-50 text-amber-700 border border-amber-200';
+ }
+
+ switch (tierClass) {
+ case 'Platinum':
+ case 'Premier':
+ case 'Founding':
+ case 'Strategic':
+ return 'bg-violet-100 text-violet-700';
+ case 'Gold':
+ case 'Steering':
+ case 'Silver':
+ return 'bg-amber-100 text-amber-700';
+ case 'General':
+ case 'Associate':
+ return 'bg-blue-100 text-blue-700';
+ case 'End User':
+ case 'Academic':
+ case 'Contributor':
+ case 'Other':
+ default:
+ return 'bg-gray-100 text-gray-600';
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html
new file mode 100644
index 000000000..d9f1c0594
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+ @if (loading()) {
+
+
+
+ @for (i of [1, 2, 3, 4]; track i) {
+
+ }
+
+
+ @for (i of [1, 2, 3, 4, 5, 6]; track i) {
+
+ }
+
+
+ } @else if (error()) {
+
+
+
Couldn't load Foundations and Projects.
+
+ Retry
+
+
+ } @else {
+
+
+
+ @if (isEmpty()) {
+
+ No foundations or projects yet.
+ } @else {
+
+
+
+
+
+ Foundation
+ Org Role
+ Voting Status
+ Governance Participation
+
+
+
+ @for (row of rows(); track row.foundationId; let i = $index) {
+
+ @if (isExpanded(row.foundationId)) {
+
+
+ @if (row.projects.length === 0) {
+ No projects from this foundation are currently active for your org.
+ } @else {
+
+ Projects Involved ({{ row.projects.length }})
+
+
+
+
+ Project
+ Maintainers
+ Contributors
+ Collaborators
+ Commits
+
+
+
+ @for (project of row.projects; track project.projectId) {
+
+
+
+
+ {{ project.projectName }}
+
+
+ {{ project.maintainers.toLocaleString() }}
+ {{ project.contributors.toLocaleString() }}
+ {{ project.collaborators.toLocaleString() }}
+ {{ project.commits.toLocaleString() }}
+
+ }
+
+
+ }
+
+
+ }
+ }
+
+
+
+ }
+ }
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss
new file mode 100644
index 000000000..f4ef3d296
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss
@@ -0,0 +1,9 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+// Layout intentionally Tailwind-utility-driven.
+// This file is a placeholder so the @Component { styleUrls } reference
+// resolves cleanly; per-element styles live in the template.
+:host {
+ display: block;
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts
new file mode 100644
index 000000000..1854ae1fa
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts
@@ -0,0 +1,214 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, effect, inject, signal } from '@angular/core';
+import { toObservable, toSignal } from '@angular/core/rxjs-interop';
+import { Router, RouterLink } from '@angular/router';
+import { TableModule } from 'primeng/table';
+import { SkeletonModule } from 'primeng/skeleton';
+import { AccountContextService } from '@services/account-context.service';
+import { OrgLensFoundationsService } from '@services/org-lens-foundations.service';
+import { PlausibleService } from '@services/plausible.service';
+import { catchError, combineLatest, map, of, startWith, switchMap, tap } from 'rxjs';
+
+import type { OrgLensFoundationRow, OrgLensFoundationsAndProjectsResponse } from '@lfx-one/shared/interfaces';
+
+import { FoundationRowComponent } from './components/foundation-row.component';
+import { FoundationsStatStripComponent } from './components/foundations-stat-strip.component';
+
+interface SectionState {
+ status: 'loading' | 'error' | 'ready' | 'empty';
+ data: OrgLensFoundationsAndProjectsResponse | null;
+}
+
+const EMPTY_RESPONSE: OrgLensFoundationsAndProjectsResponse = {
+ accountId: '',
+ accountName: '',
+ statStrip: {
+ foundations: { total: 0, breakdown: {} },
+ projects: { total: 0, leading: 0, contributing: 0, participating: 0, silent: 0 },
+ governanceRoles: { total: 0, boardMembers: 0, committeeMembers: 0 },
+ meetingsThisWeek: { total: 0, board: 0, technical: 0, marketing: 0, workingGroup: 0, other: 0 },
+ },
+ rows: [],
+};
+
+const INITIAL_STATE: SectionState = { status: 'loading', data: null };
+
+/**
+ * Parent component for the Org Lens "Foundations and Projects" section.
+ *
+ * Owns:
+ * - Data fetch keyed on the selected org id (re-fetch on switch).
+ * - Loading / error / ready / empty status signal.
+ * - Row expansion state cleared on org switch.
+ * - Retry trigger.
+ * - First-render telemetry per org (`overview_view` Plausible event).
+ *
+ * Renders:
+ * - Section header with org name + "View all on Memberships page ›" link.
+ * - 4-tile stat strip (FoundationsStatStripComponent).
+ * - Foundations table with per-row FoundationRowComponent + inline-detail expansion.
+ * - Loading skeletons / retry affordance / empty caption.
+ */
+@Component({
+ selector: 'lfx-org-overview-foundations-and-projects',
+ imports: [FoundationRowComponent, FoundationsStatStripComponent, RouterLink, SkeletonModule, TableModule],
+ templateUrl: './org-overview-foundations-and-projects.component.html',
+ styleUrls: ['./org-overview-foundations-and-projects.component.scss'],
+})
+export class OrgOverviewFoundationsAndProjectsComponent {
+ private readonly accountContextService = inject(AccountContextService);
+ private readonly foundationsService = inject(OrgLensFoundationsService);
+ private readonly plausibleService = inject(PlausibleService);
+ private readonly router = inject(Router);
+
+ protected readonly companyName = computed(() => this.accountContextService.selectedAccount().accountName || 'Your Organization');
+
+ private readonly retryTrigger = signal(0);
+
+ private readonly accountId$ = toObservable(this.accountContextService.selectedAccount).pipe(map((account) => account.accountId));
+ private readonly retryTrigger$ = toObservable(this.retryTrigger);
+
+ /**
+ * Combined stream: re-fetches when the selected account changes OR
+ * when the retry trigger ticks. `combineLatest` ensures both feed in;
+ * `startWith` primes the retry stream so the first emission fires on
+ * mount.
+ */
+ private readonly state = toSignal(
+ combineLatest([this.accountId$, this.retryTrigger$.pipe(startWith(0))]).pipe(
+ switchMap(([accountId]) => {
+ if (!accountId) {
+ return of({ status: 'empty', data: EMPTY_RESPONSE });
+ }
+ return this.foundationsService.getFoundationsAndProjects(accountId).pipe(
+ map((data) => ({
+ status: data.rows.length === 0 ? 'empty' : 'ready',
+ data,
+ })),
+ startWith({ status: 'loading', data: null }),
+ catchError(() => of({ status: 'error', data: null })),
+ tap((s) => {
+ if (s.status === 'ready' || s.status === 'empty') {
+ this.emitOverviewViewOnce(accountId);
+ }
+ })
+ );
+ })
+ ),
+ { initialValue: INITIAL_STATE }
+ );
+
+ protected readonly loading = computed(() => this.state().status === 'loading');
+ protected readonly error = computed(() => this.state().status === 'error');
+ protected readonly statStrip = computed(() => (this.state().data ?? EMPTY_RESPONSE).statStrip);
+ protected readonly rows = computed(() => (this.state().data ?? EMPTY_RESPONSE).rows);
+ protected readonly isEmpty = computed(() => this.state().status === 'empty');
+
+ // Per-row expansion state. Cleared whenever the selected account
+ // changes so previous-org expansions never leak into a new org.
+ private readonly expansionState = signal>({});
+
+ /** Telemetry de-dupe: emit `overview_view` once per org per session. */
+ private readonly viewedOrgs = new Set();
+
+ public constructor() {
+ effect(() => {
+ // Re-read selectedAccount so this effect re-runs on every change.
+ this.accountContextService.selectedAccount();
+ this.expansionState.set({});
+ });
+ }
+
+ protected isExpanded(foundationId: string): boolean {
+ return this.expansionState()[foundationId] === true;
+ }
+
+ protected toggleExpansion(foundationId: string): void {
+ this.expansionState.update((state) => {
+ const next = { ...state };
+ if (next[foundationId]) {
+ delete next[foundationId];
+ } else {
+ next[foundationId] = true;
+ }
+ return next;
+ });
+ }
+
+ protected retry(): void {
+ this.retryTrigger.update((n) => n + 1);
+ }
+
+ protected onRowClick(payload: { foundationName: string; isMember: boolean }): void {
+ const orgId = this.accountContextService.selectedAccount().accountId;
+ this.plausibleService.trackEvent('mfp_row_click', {
+ orgId,
+ foundationName: payload.foundationName,
+ isMember: payload.isMember,
+ });
+ }
+
+ protected onCaretToggle(payload: { foundationName: string; expanded: boolean }): void {
+ const orgId = this.accountContextService.selectedAccount().accountId;
+ this.plausibleService.trackEvent('mfp_caret_toggle', {
+ orgId,
+ foundationName: payload.foundationName,
+ expanded: payload.expanded,
+ });
+ }
+
+ protected onProjectClick(payload: { projectId: string; projectName: string }): void {
+ const orgId = this.accountContextService.selectedAccount().accountId;
+ this.plausibleService.trackEvent('mfp_project_row_click', {
+ orgId,
+ projectId: payload.projectId,
+ projectName: payload.projectName,
+ });
+ }
+
+ protected influenceDotClasses(influence: OrgLensFoundationRow['projects'][number]['influence']): string {
+ switch (influence) {
+ case 'Leading':
+ return 'bg-emerald-700';
+ case 'Contributing':
+ return 'bg-blue-500';
+ case 'Participating':
+ return 'bg-amber-500';
+ case 'Silent':
+ default:
+ return 'bg-gray-400';
+ }
+ }
+
+ protected projectSlugTestId(slug: string | null | undefined, projectId: string): string {
+ return slug && slug.length > 0 ? slug : projectId;
+ }
+
+ protected onProjectRowClick(project: OrgLensFoundationRow['projects'][number]): void {
+ // LF project-row clicks route to `/org/projects` (no slug, no
+ // ProjectContext mutation, no lens switch) until the slug-aware
+ // per-project drilldown destination lands as a follow-on feature.
+ // Keeping the user inside the Org Lens is intentional — switching
+ // them out via `lensService.setLens('project')` on a row click was
+ // jarring. Non-LF rows (under the Outside-LF umbrella) remain
+ // intentional no-ops (category mismatch with /org/projects).
+ if (!project.isLfProject) return;
+ this.onProjectClick({ projectId: project.projectId, projectName: project.projectName });
+ void this.router.navigate(['/org/projects']);
+ }
+
+ protected onProjectRowKeydown(event: KeyboardEvent, project: OrgLensFoundationRow['projects'][number]): void {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ if (!project.isLfProject) return;
+ event.preventDefault();
+ this.onProjectRowClick(project);
+ }
+
+ private emitOverviewViewOnce(accountId: string): void {
+ if (this.viewedOrgs.has(accountId)) return;
+ this.viewedOrgs.add(accountId);
+ this.plausibleService.trackEvent('overview_view', { orgId: accountId });
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html
new file mode 100644
index 000000000..6f254b6d5
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
{{ accountName() }}'s Involvement
+ {{ subtitleText() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (metric of primaryMetrics(); track metric.testId) {
+
+ }
+
+
+
+
+
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss
new file mode 100644
index 000000000..121c97271
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss
@@ -0,0 +1,11 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+.hide-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+
+ &::-webkit-scrollbar {
+ display: none; /* Chrome, Safari and Opera */
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts
new file mode 100644
index 000000000..9499f9cbf
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts
@@ -0,0 +1,492 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, inject, signal, viewChild } from '@angular/core';
+import { toObservable, toSignal } from '@angular/core/rxjs-interop';
+import { FilterPillsComponent } from '@components/filter-pills/filter-pills.component';
+import { MetricCardComponent } from '@components/metric-card/metric-card.component';
+import { BASE_BAR_CHART_OPTIONS, BASE_LINE_CHART_OPTIONS, lfxColors, ORG_INVOLVEMENT_METRICS } from '@lfx-one/shared/constants';
+import { hexToRgba } from '@lfx-one/shared/utils';
+import { AccountContextService } from '@services/account-context.service';
+import { OrgInvolvementAnalyticsService } from '@services/org-involvement-analytics.service';
+import { ScrollShadowDirective } from '@shared/directives/scroll-shadow.directive';
+import { catchError, map, of, switchMap, tap } from 'rxjs';
+
+import type {
+ OrgInvolvementCertifiedEmployeesMonthlyResponse,
+ OrgInvolvementContributorsMonthlyResponse,
+ OrgInvolvementEventAttendanceMonthlyResponse,
+ OrgFoundationCoverageResponse,
+ OrgInvolvementMaintainersMonthlyResponse,
+ OrgTrainingEnrollmentsResponse,
+} from '@lfx-one/shared/interfaces/org-involvement.interface';
+import type { DashboardMetricCard, FilterPillOption } from '@lfx-one/shared/interfaces';
+import type { ChartOptions, ChartType } from 'chart.js';
+
+@Component({
+ selector: 'lfx-org-overview-involvement',
+ imports: [FilterPillsComponent, MetricCardComponent, ScrollShadowDirective],
+ templateUrl: './org-overview-involvement.component.html',
+ styleUrl: './org-overview-involvement.component.scss',
+})
+export class OrgOverviewInvolvementComponent {
+ public readonly scrollShadowDirective = viewChild(ScrollShadowDirective);
+
+ private readonly analyticsService = inject(OrgInvolvementAnalyticsService);
+ private readonly accountContextService = inject(AccountContextService);
+
+ private readonly maintainersLoading = signal(true);
+ private readonly contributorsLoading = signal(true);
+ private readonly certifiedEmployeesLoading = signal(true);
+ private readonly trainingEnrollmentsLoading = signal(true);
+ private readonly eventsLoading = signal(true);
+ private readonly coverageLoading = signal(true);
+
+ private readonly selectedAccountId$ = toObservable(this.accountContextService.selectedAccount).pipe(map((account) => account.accountId));
+
+ private readonly coverageData = this.initializeCoverageData();
+ private readonly maintainersData = this.initializeMaintainersData();
+ private readonly contributorsData = this.initializeContributorsData();
+ private readonly certifiedEmployeesData = this.initializeCertifiedEmployeesData();
+ private readonly trainingEnrollmentsData = this.initializeTrainingEnrollmentsData();
+ private readonly eventAttendanceMonthlyData = this.initializeEventAttendanceMonthlyData();
+
+ public readonly selectedFilter = signal('all');
+ public readonly accountName = computed(() => this.accountContextService.selectedAccount().accountName || 'Organization');
+
+ public readonly subtitleText = computed(() => {
+ const coverage = this.coverageData();
+ if (this.coverageLoading()) {
+ return '';
+ }
+ return coverage.foundationCount > 0 ? `across ${coverage.foundationCount} LF foundations` : 'No engagement yet';
+ });
+
+ public readonly filterOptions: FilterPillOption[] = [
+ { id: 'all', label: 'All' },
+ { id: 'contributions', label: 'Contribution' },
+ { id: 'events', label: 'Event' },
+ { id: 'education', label: 'Education' },
+ ];
+
+ private readonly activeContributorsCard = this.initializeActiveContributorsCard();
+ private readonly maintainersCard = this.initializeMaintainersCard();
+ private readonly eventAttendeesCard = this.initializeEventAttendeesCard();
+ private readonly eventSpeakersCard = this.initializeEventSpeakersCard();
+ private readonly certifiedEmployeesCard = this.initializeCertifiedEmployeesCard();
+ private readonly trainingEnrollmentsCard = this.initializeTrainingEnrollmentsCard();
+
+ public readonly primaryMetrics = this.initializePrimaryMetrics();
+
+ public handleFilterChange(filter: string): void {
+ this.selectedFilter.set(filter);
+ }
+
+ private getMetricConfig(title: string): DashboardMetricCard {
+ return ORG_INVOLVEMENT_METRICS.find((m) => m.title === title)!;
+ }
+
+ private initializeActiveContributorsCard() {
+ return computed(() => this.transformActiveContributors(this.contributorsData(), this.getMetricConfig('Active Contributors')));
+ }
+
+ private initializeMaintainersCard() {
+ return computed(() => this.transformMaintainers(this.maintainersData(), this.getMetricConfig('Maintainers')));
+ }
+
+ private initializeEventAttendeesCard() {
+ return computed(() => this.transformEventAttendees(this.eventAttendanceMonthlyData(), this.getMetricConfig('Event Attendees')));
+ }
+
+ private initializeEventSpeakersCard() {
+ return computed(() => this.transformEventSpeakers(this.eventAttendanceMonthlyData(), this.getMetricConfig('Event Speakers')));
+ }
+
+ private initializeCertifiedEmployeesCard() {
+ return computed(() => this.transformCertifiedEmployees(this.certifiedEmployeesData(), this.getMetricConfig('Certified Employees')));
+ }
+
+ private initializeTrainingEnrollmentsCard() {
+ return computed(() => this.transformTrainingEnrollments(this.trainingEnrollmentsData(), this.getMetricConfig('Training Enrollments')));
+ }
+
+ private initializePrimaryMetrics() {
+ return computed(() => {
+ const filter = this.selectedFilter();
+
+ const allCards = [
+ { card: this.activeContributorsCard(), category: 'contributions' },
+ { card: this.maintainersCard(), category: 'contributions' },
+ { card: this.eventAttendeesCard(), category: 'events' },
+ { card: this.eventSpeakersCard(), category: 'events' },
+ { card: this.certifiedEmployeesCard(), category: 'education' },
+ { card: this.trainingEnrollmentsCard(), category: 'education' },
+ ];
+
+ if (filter === 'all') {
+ return allCards.map((item) => item.card);
+ }
+
+ return allCards.filter((item) => item.category === filter).map((item) => item.card);
+ });
+ }
+
+ private initializeCoverageData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.coverageLoading.set(true);
+ return this.analyticsService.getFoundationCoverage(accountId).pipe(
+ tap(() => this.coverageLoading.set(false)),
+ catchError(() => {
+ this.coverageLoading.set(false);
+ return of({ accountId: '', foundationCount: 0, foundations: [] } as OrgFoundationCoverageResponse);
+ })
+ );
+ })
+ ),
+ { initialValue: { accountId: '', foundationCount: 0, foundations: [] } as OrgFoundationCoverageResponse }
+ );
+ }
+
+ private initializeMaintainersData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.maintainersLoading.set(true);
+ return this.analyticsService.getMaintainersMonthly(accountId).pipe(
+ tap(() => this.maintainersLoading.set(false)),
+ catchError(() => {
+ this.maintainersLoading.set(false);
+ return of({
+ accountId: '',
+ accountName: '',
+ totalMaintainersYearly: 0,
+ totalProjectsYearly: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementMaintainersMonthlyResponse);
+ })
+ );
+ })
+ ),
+ {
+ initialValue: {
+ accountId: '',
+ accountName: '',
+ totalMaintainersYearly: 0,
+ totalProjectsYearly: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementMaintainersMonthlyResponse,
+ }
+ );
+ }
+
+ private initializeContributorsData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.contributorsLoading.set(true);
+ return this.analyticsService.getContributorsMonthly(accountId).pipe(
+ tap(() => this.contributorsLoading.set(false)),
+ catchError(() => {
+ this.contributorsLoading.set(false);
+ return of({
+ accountId: '',
+ totalActiveContributors: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementContributorsMonthlyResponse);
+ })
+ );
+ })
+ ),
+ {
+ initialValue: {
+ accountId: '',
+ totalActiveContributors: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementContributorsMonthlyResponse,
+ }
+ );
+ }
+
+ private initializeCertifiedEmployeesData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.certifiedEmployeesLoading.set(true);
+ return this.analyticsService.getCertifiedEmployeesMonthly(accountId).pipe(
+ tap(() => this.certifiedEmployeesLoading.set(false)),
+ catchError(() => {
+ this.certifiedEmployeesLoading.set(false);
+ return of({
+ accountId: '',
+ totalCertifications: 0,
+ totalCertifiedEmployees: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementCertifiedEmployeesMonthlyResponse);
+ })
+ );
+ })
+ ),
+ {
+ initialValue: {
+ accountId: '',
+ totalCertifications: 0,
+ totalCertifiedEmployees: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementCertifiedEmployeesMonthlyResponse,
+ }
+ );
+ }
+
+ private initializeTrainingEnrollmentsData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.trainingEnrollmentsLoading.set(true);
+ return this.analyticsService.getTrainingEnrollments(accountId).pipe(
+ tap(() => this.trainingEnrollmentsLoading.set(false)),
+ catchError(() => {
+ this.trainingEnrollmentsLoading.set(false);
+ return of({ accountId: '', totalEnrollments: 0, dailyData: [] } as OrgTrainingEnrollmentsResponse);
+ })
+ );
+ })
+ ),
+ { initialValue: { accountId: '', totalEnrollments: 0, dailyData: [] } as OrgTrainingEnrollmentsResponse }
+ );
+ }
+
+ private initializeEventAttendanceMonthlyData() {
+ return toSignal(
+ this.selectedAccountId$.pipe(
+ switchMap((accountId) => {
+ this.eventsLoading.set(true);
+ return this.analyticsService.getEventAttendanceMonthly(accountId).pipe(
+ tap(() => this.eventsLoading.set(false)),
+ catchError(() => {
+ this.eventsLoading.set(false);
+ return of({
+ accountId: '',
+ accountName: '',
+ totalAttended: 0,
+ totalSpeakers: 0,
+ attendeesMonthlyData: [],
+ speakersMonthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementEventAttendanceMonthlyResponse);
+ })
+ );
+ })
+ ),
+ {
+ initialValue: {
+ accountId: '',
+ accountName: '',
+ totalAttended: 0,
+ totalSpeakers: 0,
+ attendeesMonthlyData: [],
+ speakersMonthlyData: [],
+ monthlyLabels: [],
+ } as OrgInvolvementEventAttendanceMonthlyResponse,
+ }
+ );
+ }
+
+ private transformActiveContributors(data: OrgInvolvementContributorsMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ return {
+ ...metric,
+ loading: this.contributorsLoading(),
+ value: data.totalActiveContributors.toString(),
+ subtitle: 'Employees actively contributing to projects',
+ chartOptions: this.createBarChartOptions('Active contributors'),
+ chartData:
+ data.monthlyData.length > 0
+ ? {
+ labels: data.monthlyLabels,
+ datasets: [
+ {
+ data: data.monthlyData,
+ borderColor: lfxColors.blue[500],
+ backgroundColor: hexToRgba(lfxColors.blue[500], 0.5),
+ borderWidth: 0,
+ borderRadius: 4,
+ },
+ ],
+ }
+ : metric.chartData,
+ };
+ }
+
+ private transformMaintainers(data: OrgInvolvementMaintainersMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ const projectLabel = data.totalProjectsYearly === 1 ? 'project' : 'projects';
+ return {
+ ...metric,
+ loading: this.maintainersLoading(),
+ value: data.totalMaintainersYearly.toString(),
+ subtitle: `Employees stewarding ${projectLabel} across foundations`,
+ chartOptions: this.createBarChartOptions('Maintainers'),
+ chartData:
+ data.monthlyData.length > 0
+ ? {
+ labels: data.monthlyLabels,
+ datasets: [
+ {
+ data: data.monthlyData,
+ borderColor: lfxColors.blue[500],
+ backgroundColor: hexToRgba(lfxColors.blue[500], 0.5),
+ borderWidth: 0,
+ borderRadius: 4,
+ },
+ ],
+ }
+ : metric.chartData,
+ };
+ }
+
+ private transformEventAttendees(data: OrgInvolvementEventAttendanceMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ return {
+ ...metric,
+ loading: this.eventsLoading(),
+ value: data.totalAttended.toString(),
+ subtitle: 'Employees at foundation events',
+ chartOptions: this.createLineChartOptions('Event attendees'),
+ chartData:
+ data.attendeesMonthlyData.length > 0
+ ? {
+ labels: data.monthlyLabels,
+ datasets: [
+ {
+ data: data.attendeesMonthlyData,
+ borderColor: lfxColors.emerald[500],
+ backgroundColor: hexToRgba(lfxColors.emerald[500], 0.1),
+ fill: true,
+ tension: 0,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ }
+ : metric.chartData,
+ };
+ }
+
+ private transformEventSpeakers(data: OrgInvolvementEventAttendanceMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ return {
+ ...metric,
+ loading: this.eventsLoading(),
+ value: data.totalSpeakers.toString(),
+ subtitle: 'Employee speakers at events',
+ chartOptions: this.createLineChartOptions('Event speakers'),
+ chartData:
+ data.speakersMonthlyData.length > 0
+ ? {
+ labels: data.monthlyLabels,
+ datasets: [
+ {
+ data: data.speakersMonthlyData,
+ borderColor: lfxColors.amber[500],
+ backgroundColor: hexToRgba(lfxColors.amber[500], 0.1),
+ fill: true,
+ tension: 0,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ }
+ : metric.chartData,
+ };
+ }
+
+ private transformCertifiedEmployees(data: OrgInvolvementCertifiedEmployeesMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ return {
+ ...metric,
+ loading: this.certifiedEmployeesLoading(),
+ value: `${data.totalCertifiedEmployees} employees`,
+ subtitle: `${data.totalCertifications} total certifications`,
+ chartOptions: this.createLineChartOptions('Certifications'),
+ chartData:
+ data.monthlyData.length > 0
+ ? {
+ labels: data.monthlyLabels,
+ datasets: [
+ {
+ data: data.monthlyData,
+ borderColor: lfxColors.violet[500],
+ backgroundColor: hexToRgba(lfxColors.violet[500], 0.1),
+ fill: true,
+ tension: 0,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ }
+ : metric.chartData,
+ };
+ }
+
+ private transformTrainingEnrollments(data: OrgTrainingEnrollmentsResponse, metric: DashboardMetricCard): DashboardMetricCard {
+ return {
+ ...metric,
+ loading: this.trainingEnrollmentsLoading(),
+ value: data.totalEnrollments.toString(),
+ subtitle: 'Training courses enrolled this year',
+ chartOptions: this.createLineChartOptions('Training enrollments'),
+ chartData: {
+ labels: data.dailyData.map((row) => {
+ const date = new Date(row.date);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ }),
+ datasets: [
+ {
+ data: data.dailyData.map((row) => row.cumulativeCount),
+ borderColor: lfxColors.blue[500],
+ backgroundColor: hexToRgba(lfxColors.blue[500], 0.1),
+ fill: true,
+ tension: 0,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ };
+ }
+
+ private createBarChartOptions(label: string): ChartOptions {
+ return {
+ ...BASE_BAR_CHART_OPTIONS,
+ plugins: {
+ ...BASE_BAR_CHART_OPTIONS.plugins,
+ tooltip: {
+ ...(BASE_BAR_CHART_OPTIONS.plugins?.tooltip ?? {}),
+ callbacks: {
+ title: (context) => context[0]?.label ?? '',
+ label: (context) => `${label}: ${context.parsed.y ?? 0}`,
+ },
+ },
+ },
+ };
+ }
+
+ private createLineChartOptions(label: string): ChartOptions {
+ return {
+ ...BASE_LINE_CHART_OPTIONS,
+ plugins: {
+ ...BASE_LINE_CHART_OPTIONS.plugins,
+ tooltip: {
+ ...(BASE_LINE_CHART_OPTIONS.plugins?.tooltip ?? {}),
+ callbacks: {
+ title: (context) => context[0]?.label ?? '',
+ label: (context) => `${label}: ${context.parsed.y ?? 0}`,
+ },
+ },
+ },
+ };
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html
new file mode 100644
index 000000000..04692bf09
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ {{ title() }}
+ {{ description() }}
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts
new file mode 100644
index 000000000..e55245993
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts
@@ -0,0 +1,28 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, inject, Signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, Data } from '@angular/router';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
+
+interface OrgPlaceholderRouteData {
+ title?: string;
+ description?: string;
+ icon?: string;
+}
+
+@Component({
+ selector: 'lfx-org-placeholder-page',
+ imports: [EmptyStateComponent],
+ templateUrl: './org-placeholder-page.component.html',
+})
+export class OrgPlaceholderPageComponent {
+ private readonly route = inject(ActivatedRoute);
+
+ private readonly routeData: Signal = toSignal(this.route.data, { initialValue: {} as Data });
+
+ protected readonly title = computed(() => (this.routeData() as OrgPlaceholderRouteData).title ?? 'Coming Soon');
+ protected readonly description = computed(() => (this.routeData() as OrgPlaceholderRouteData).description ?? 'This view is in development.');
+ protected readonly icon = computed(() => (this.routeData() as OrgPlaceholderRouteData).icon ?? 'fa-light fa-screwdriver-wrench');
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html
new file mode 100644
index 000000000..94bab15b0
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ {{ companyName() }} Overview
+ @if (tierLabel(); as tier) {
+
+ }
+
+
+
+ @defer (on viewport) {
+
+ } @placeholder {
+
+
+
+ @for (i of [1, 2, 3]; track i) {
+
+ }
+
+
+ }
+
+
+ @defer (on viewport) {
+
+ } @placeholder {
+
+
+
+ @for (i of [1, 2, 3, 4]; track i) {
+
+ }
+
+
+ }
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts
new file mode 100644
index 000000000..4e693f24a
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts
@@ -0,0 +1,25 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, inject, Signal } from '@angular/core';
+import { TagComponent } from '@components/tag/tag.component';
+import { SkeletonModule } from 'primeng/skeleton';
+import { AccountContextService } from '@services/account-context.service';
+
+import { OrgOverviewFoundationsAndProjectsComponent } from '../components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component';
+import { OrgOverviewInvolvementComponent } from '../components/org-overview-involvement/org-overview-involvement.component';
+
+@Component({
+ selector: 'lfx-org-overview',
+ imports: [TagComponent, SkeletonModule, OrgOverviewInvolvementComponent, OrgOverviewFoundationsAndProjectsComponent],
+ templateUrl: './org-overview.component.html',
+})
+export class OrgOverviewComponent {
+ private readonly accountContextService = inject(AccountContextService);
+
+ protected readonly selectedAccount = this.accountContextService.selectedAccount;
+
+ protected readonly companyName: Signal = computed(() => this.selectedAccount().accountName || 'Your Organization');
+
+ protected readonly tierLabel: Signal = computed(() => this.selectedAccount().membershipTier ?? null);
+}
diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html
new file mode 100644
index 000000000..dbddd7083
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+ @if (displayLogo()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+ Organization
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (account of visibleAccounts(); track account.accountId) {
+
+
+
+ @if (account.logoUrl) {
+
+ } @else {
+
+ }
+
+
+
+
+
{{ account.accountName }}
+
+
+ @if (isSelected(account)) {
+
+ }
+
+ } @empty {
+
No organizations found
+ }
+
+
+
diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss
new file mode 100644
index 000000000..90e919e7d
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss
@@ -0,0 +1,29 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+:host {
+ display: block;
+ width: 100%;
+}
+
+.sidebar-org-name {
+ @apply font-medium leading-5 relative text-base text-gray-900 tracking-tight truncate w-full text-left;
+}
+
+::ng-deep .org-selector-panel.p-popover {
+ &::before,
+ &::after {
+ display: none !important;
+ }
+
+ @media (min-width: 1024px) {
+ // stylelint-disable-next-line declaration-no-important
+ position: fixed !important;
+ left: 344px !important;
+ top: 4px !important;
+
+ &.org-selector-panel--with-banner {
+ top: 52px !important;
+ }
+ }
+}
diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts
new file mode 100644
index 000000000..d101a3888
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts
@@ -0,0 +1,71 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component, computed, inject, model, Signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { Account } from '@lfx-one/shared/interfaces';
+import { AccountContextService } from '@services/account-context.service';
+import { UserService } from '@services/user.service';
+import { AutoFocus } from 'primeng/autofocus';
+import { InputTextModule } from 'primeng/inputtext';
+import { Popover, PopoverModule } from 'primeng/popover';
+
+@Component({
+ selector: 'lfx-org-selector',
+ imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus],
+ templateUrl: './org-selector.component.html',
+ styleUrl: './org-selector.component.scss',
+})
+export class OrgSelectorComponent {
+ private readonly accountContextService = inject(AccountContextService);
+ private readonly userService = inject(UserService);
+
+ public readonly isPanelOpen = model(false);
+
+ protected readonly searchControl = new FormControl('', { nonNullable: true });
+ private readonly searchTerm: Signal = computed(() => (this.searchValue() ?? '').trim().toLowerCase());
+ private readonly searchValue = toSignal(this.searchControl.valueChanges, { initialValue: '' });
+
+ protected readonly panelStyleClass = computed(() =>
+ this.userService.impersonating() ? 'org-selector-panel org-selector-panel--with-banner' : 'org-selector-panel'
+ );
+
+ protected readonly selectedAccount: Signal = this.accountContextService.selectedAccount;
+ protected readonly availableAccounts: Signal = this.accountContextService.availableAccounts;
+
+ protected readonly displayName: Signal = computed(() => this.selectedAccount().accountName || 'Select Organization');
+
+ protected readonly displayLogo: Signal = computed(() => this.selectedAccount().logoUrl ?? '');
+
+ protected readonly visibleAccounts: Signal = computed(() => {
+ const term = this.searchTerm();
+ const accounts = this.availableAccounts();
+ if (!term) {
+ return accounts;
+ }
+ return accounts.filter((account) => account.accountName.toLowerCase().includes(term));
+ });
+
+ protected selectItem(account: Account, popover: Popover): void {
+ this.accountContextService.setAccount(account);
+ popover.hide();
+ }
+
+ protected togglePanel(event: Event, popover: Popover): void {
+ popover.toggle(event);
+ }
+
+ protected onPopoverShow(): void {
+ this.isPanelOpen.set(true);
+ }
+
+ protected onPopoverHide(): void {
+ this.isPanelOpen.set(false);
+ this.searchControl.setValue('', { emitEvent: true });
+ }
+
+ protected isSelected(account: Account): boolean {
+ return account.accountId === this.selectedAccount().accountId;
+ }
+}
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html
index c6db941ef..355a41031 100644
--- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html
+++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html
@@ -28,6 +28,12 @@
}
+ @if (showOrgSelector()) {
+
+
+
+ }
+
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts
index c02016153..911d8400f 100644
--- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts
+++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts
@@ -6,6 +6,7 @@ import { Component, computed, inject, input, model, Signal } from '@angular/core
import { RouterModule } from '@angular/router';
import { AvatarComponent } from '@components/avatar/avatar.component';
import { BadgeComponent } from '@components/badge/badge.component';
+import { OrgSelectorComponent } from '@components/org-selector/org-selector.component';
import { ProjectSelectorComponent } from '@components/project-selector/project-selector.component';
import { environment } from '@environments/environment';
import { PERSONA_OPTIONS, PERSONA_PRIORITY } from '@lfx-one/shared/constants';
@@ -27,7 +28,7 @@ const PERSONA_ICONS: Partial
> = {
@Component({
selector: 'lfx-sidebar',
- imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule],
+ imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, OrgSelectorComponent, ProjectSelectorComponent, SkeletonModule],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.scss',
})
@@ -43,6 +44,7 @@ export class SidebarComponent {
public readonly collapsed = input(false);
public readonly styleClass = input('');
public readonly showProjectSelector = input(false);
+ public readonly showOrgSelector = input(false);
public readonly showMeSelector = input(false);
public readonly mobile = input(false);
public readonly selectorPanelOpen = model(false);
@@ -100,7 +102,7 @@ export class SidebarComponent {
private initLensLoaded(): Signal {
return computed(() => {
- if (this.isOrgLens()) return false;
+ if (this.isOrgLens()) return true;
const lens = this.navLens();
if (!lens) return true;
return this.navigationService.loaded(lens)();
diff --git a/apps/lfx-one/src/app/shared/services/account-context.service.ts b/apps/lfx-one/src/app/shared/services/account-context.service.ts
index 6af5053e7..b0a651967 100644
--- a/apps/lfx-one/src/app/shared/services/account-context.service.ts
+++ b/apps/lfx-one/src/app/shared/services/account-context.service.ts
@@ -2,120 +2,194 @@
// SPDX-License-Identifier: MIT
import { computed, inject, Injectable, Signal, signal, WritableSignal } from '@angular/core';
-import { ACCOUNT_COOKIE_KEY, ACCOUNTS, DEFAULT_ACCOUNT } from '@lfx-one/shared/constants';
-import { Account } from '@lfx-one/shared/interfaces';
+import { ACCOUNT_COOKIE_KEY } from '@lfx-one/shared/constants';
+import { Account, OrgLensAccountContextResponse } from '@lfx-one/shared/interfaces';
import { SsrCookieService } from 'ngx-cookie-service-ssr';
+import { AnalyticsService } from './analytics.service';
import { CookieRegistryService } from './cookie-registry.service';
+const PLACEHOLDER_ACCOUNT: Account = {
+ accountId: '',
+ accountName: '',
+ accountSlug: '',
+ membershipTier: '',
+};
+
@Injectable({
providedIn: 'root',
})
export class AccountContextService {
private readonly cookieService = inject(SsrCookieService);
private readonly cookieRegistry = inject(CookieRegistryService);
+ private readonly analyticsService = inject(AnalyticsService);
private readonly storageKey = ACCOUNT_COOKIE_KEY;
/**
- * User's organizations from committee memberships (filtered from ACCOUNTS)
- * If empty, falls back to all available accounts
+ * Seed organizations from the persona service — the accounts the
+ * current user is authorised to see. Display attributes (slug, logo,
+ * tier) are resolved from Snowflake via getOrgLensAccountContext.
*/
private readonly userOrganizations: WritableSignal = signal([]);
- /**
- * Whether user organizations have been initialized from auth context
- */
private readonly initialized: WritableSignal = signal(false);
/**
- * The currently selected account
+ * Snowflake-resolved Account records keyed by accountId. Flat — the
+ * UI renders one entry per account regardless of any Salesforce
+ * conglomerate hierarchy that may exist behind the scenes.
*/
+ private readonly liveAccounts: WritableSignal> = signal(new Map());
+
public readonly selectedAccount: WritableSignal;
/**
- * Returns available accounts for the user
- * Merges detected organizations into the predefined ACCOUNTS list so that
- * organizations not in the hardcoded list are still available for selection
+ * Accounts visible in the org-selector — one row per persona-authorised
+ * account, enriched with Snowflake display attributes once available,
+ * falling back to bare seed records while live data is loading so the
+ * selector is never empty between bootstrap and the first response.
*/
public readonly availableAccounts: Signal = computed(() => {
- const detected = this.userOrganizations();
- if (!this.initialized() || detected.length === 0) {
- return ACCOUNTS;
+ const seeds = this.userOrganizations();
+ const live = this.liveAccounts();
+
+ if (live.size === 0) {
+ return seeds;
}
- // Start with ACCOUNTS, then append any detected orgs not already in the list
- const knownIds = new Set(ACCOUNTS.map((a) => a.accountId));
- const extras = detected.filter((d) => !knownIds.has(d.accountId));
- return extras.length > 0 ? [...ACCOUNTS, ...extras] : ACCOUNTS;
+ const seen = new Set();
+ const result: Account[] = [];
+ for (const seed of seeds) {
+ if (seen.has(seed.accountId)) {
+ continue;
+ }
+ seen.add(seed.accountId);
+ result.push(live.get(seed.accountId) ?? seed);
+ }
+ return result;
});
public constructor() {
const stored = this.loadFromStorage();
- this.selectedAccount = signal(stored || DEFAULT_ACCOUNT);
+ this.selectedAccount = signal(stored ?? PLACEHOLDER_ACCOUNT);
}
/**
- * Initialize user organizations from auth context (SSR state transfer)
- * Called during app initialization with organizations matched from committee memberships
+ * Initialize user organizations from auth context (SSR state transfer).
+ *
+ * Sets the seed list from the persona service, then fetches
+ * /api/analytics/org-lens-account-context to enrich each seed with
+ * its Snowflake display attributes (slug, logo, cdev mapping, tier).
+ * Selection is reconciled against the seeds and re-enriched once the
+ * live data arrives.
*/
public initializeUserOrganizations(organizations: Account[]): void {
this.initialized.set(true);
- this.userOrganizations.set(organizations ?? []);
-
- if (organizations && organizations.length > 0) {
- // Validate stored selection against the user's detected organizations.
- // A stored selection from a prior session (or a leaked impersonator cookie)
- // must match one of the currently detected orgs; otherwise fall back to
- // the first detected org so the selector reflects the active context.
- // Resolve to the canonical Account from organizations so we never trust
- // cookie-supplied fields (e.g. accountName) beyond the validated accountId.
- const stored = this.loadFromStorage();
- const matchedOrganization = stored ? (organizations.find((org) => org.accountId === stored.accountId) ?? null) : null;
+ const seeds = organizations ?? [];
+ this.userOrganizations.set(seeds);
- if (matchedOrganization) {
- this.selectedAccount.set(matchedOrganization);
+ if (seeds.length > 0) {
+ const stored = this.loadFromStorage();
+ const matchedSeed = stored ? (seeds.find((seed) => seed.accountId === stored.accountId) ?? null) : null;
+ if (matchedSeed) {
+ this.setAccount(matchedSeed);
} else {
- this.setAccount(organizations[0]);
+ this.setAccount(seeds[0]);
}
}
+
+ this.refreshFromSnowflake(seeds.map((seed) => seed.accountId));
}
- /**
- * Set the selected account and persist to storage
- */
public setAccount(account: Account): void {
- this.selectedAccount.set(account);
- this.persistToStorage(account);
+ const live = this.liveAccounts().get(account.accountId);
+ const next = live ?? account;
+ this.selectedAccount.set(next);
+ this.persistToStorage(next);
}
- /**
- * Get the currently selected account ID
- */
public getAccountId(): string {
return this.selectedAccount().accountId;
}
+ private refreshFromSnowflake(accountIds: string[]): void {
+ const ids = [...new Set(accountIds.filter((id) => !!id))];
+ if (ids.length === 0) {
+ return;
+ }
+
+ this.analyticsService.getOrgLensAccountContext(ids).subscribe((rows) => {
+ if (rows.length === 0) {
+ return;
+ }
+ const live = this.buildLiveAccounts(rows);
+ this.liveAccounts.set(live);
+
+ const current = this.selectedAccount();
+ const liveCurrent = live.get(current.accountId);
+ if (liveCurrent) {
+ this.selectedAccount.set(liveCurrent);
+ } else if (!current.accountId) {
+ const firstSeed = this.userOrganizations()[0];
+ if (firstSeed) {
+ const liveSeed = live.get(firstSeed.accountId) ?? firstSeed;
+ this.selectedAccount.set(liveSeed);
+ this.persistToStorage(liveSeed);
+ }
+ }
+ });
+ }
+
+ private buildLiveAccounts(rows: OrgLensAccountContextResponse[]): Map {
+ const accounts = new Map();
+ for (const row of rows) {
+ const account = this.toAccount(row);
+ accounts.set(account.accountId, account);
+ }
+ return accounts;
+ }
+
+ private toAccount(row: OrgLensAccountContextResponse): Account {
+ return {
+ accountId: row.accountId,
+ accountName: row.accountName,
+ accountSlug: row.accountSlug ?? '',
+ logoUrl: row.logoUrl ?? undefined,
+ cdevOrgId: row.cdevOrgId ?? undefined,
+ membershipTier: row.membershipTierDisplayName ?? '',
+ };
+ }
+
private persistToStorage(account: Account): void {
- // Store in cookie (SSR-compatible)
this.cookieService.set(this.storageKey, JSON.stringify(account), {
- expires: 30, // 30 days
+ expires: 30,
path: '/',
sameSite: 'Lax',
secure: process.env['NODE_ENV'] === 'production',
});
- // Register cookie for tracking
this.cookieRegistry.registerCookie(this.storageKey);
}
private loadFromStorage(): Account | null {
try {
const stored = this.cookieService.get(this.storageKey);
- if (stored) {
- return JSON.parse(stored) as Account;
+ if (!stored) {
+ return null;
}
+ const parsed = JSON.parse(stored) as Account;
+ if (!this.isValidAccountId(parsed?.accountId)) {
+ return null;
+ }
+ return parsed;
} catch {
- // Invalid data in cookie, ignore
+ return null;
}
- return null;
+ }
+
+ // Salesforce account ids are 15- or 18-char alphanumeric strings.
+ // Reject anything else so a tampered cookie can't seed selectedAccount with
+ // an invalid id before persona init reconciles.
+ private isValidAccountId(id: unknown): id is string {
+ return typeof id === 'string' && /^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$/.test(id);
}
}
diff --git a/apps/lfx-one/src/app/shared/services/analytics.service.ts b/apps/lfx-one/src/app/shared/services/analytics.service.ts
index 9d5069fed..c3cf937d9 100644
--- a/apps/lfx-one/src/app/shared/services/analytics.service.ts
+++ b/apps/lfx-one/src/app/shared/services/analytics.service.ts
@@ -4,6 +4,7 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import {
+ OrgLensAccountContextResponse,
ActiveWeeksStreakResponse,
CertifiedEmployeesResponse,
CodeCommitsDailyResponse,
@@ -206,6 +207,23 @@ export class AnalyticsService {
);
}
+ /**
+ * Bootstrap the Org Lens account context for the user's persona-authorised
+ * Salesforce accounts. Returns a single denormalised row per account_id
+ * with display attributes, Crowd.dev mapping, and the highest active
+ * corporate membership tier — drives both the org-selector dropdown and
+ * the header badge.
+ */
+ public getOrgLensAccountContext(accountIds: string[]): Observable {
+ if (accountIds.length === 0) {
+ return of([]);
+ }
+ const params = { accountIds: accountIds.join(',') };
+ return this.http
+ .get('/api/analytics/org-lens-account-context', { params })
+ .pipe(catchError(() => of([] as OrgLensAccountContextResponse[])));
+ }
+
/**
* Get certified employees data for an organization with monthly trend
* @param accountId - Required account ID to filter by specific organization
diff --git a/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts b/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts
new file mode 100644
index 000000000..fbcdc5863
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts
@@ -0,0 +1,108 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { catchError, Observable, of } from 'rxjs';
+
+import type {
+ OrgFoundationCoverageResponse,
+ OrgInvolvementCertifiedEmployeesMonthlyResponse,
+ OrgInvolvementContributorsMonthlyResponse,
+ OrgInvolvementEventAttendanceMonthlyResponse,
+ OrgInvolvementMaintainersMonthlyResponse,
+ OrgTrainingEnrollmentsResponse,
+} from '@lfx-one/shared/interfaces/org-involvement.interface';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class OrgInvolvementAnalyticsService {
+ private readonly http = inject(HttpClient);
+
+ public getFoundationCoverage(accountId: string): Observable {
+ return this.http.get('/api/analytics/org-foundation-coverage', { params: { accountId } }).pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ foundationCount: 0,
+ foundations: [],
+ })
+ )
+ );
+ }
+
+ public getContributorsMonthly(accountId: string): Observable {
+ return this.http.get('/api/analytics/org-involvement-contributors-monthly', { params: { accountId } }).pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ totalActiveContributors: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ })
+ )
+ );
+ }
+
+ public getMaintainersMonthly(accountId: string): Observable {
+ return this.http.get('/api/analytics/org-involvement-maintainers-monthly', { params: { accountId } }).pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ accountName: '',
+ totalMaintainersYearly: 0,
+ totalProjectsYearly: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ })
+ )
+ );
+ }
+
+ public getEventAttendanceMonthly(accountId: string): Observable {
+ return this.http
+ .get('/api/analytics/org-involvement-event-attendance-monthly', { params: { accountId } })
+ .pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ accountName: '',
+ totalAttended: 0,
+ totalSpeakers: 0,
+ attendeesMonthlyData: [],
+ speakersMonthlyData: [],
+ monthlyLabels: [],
+ })
+ )
+ );
+ }
+
+ public getCertifiedEmployeesMonthly(accountId: string): Observable {
+ return this.http
+ .get('/api/analytics/org-involvement-certified-employees-monthly', { params: { accountId } })
+ .pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ totalCertifications: 0,
+ totalCertifiedEmployees: 0,
+ monthlyData: [],
+ monthlyLabels: [],
+ })
+ )
+ );
+ }
+
+ public getTrainingEnrollments(accountId: string): Observable {
+ return this.http.get('/api/analytics/org-involvement-training-enrollments', { params: { accountId } }).pipe(
+ catchError(() =>
+ of({
+ accountId: '',
+ totalEnrollments: 0,
+ dailyData: [],
+ })
+ )
+ );
+ }
+}
diff --git a/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts
new file mode 100644
index 000000000..dbcb55648
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts
@@ -0,0 +1,23 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import type { OrgLensFoundationsAndProjectsResponse } from '@lfx-one/shared/interfaces';
+
+/**
+ * Client-side proxy for GET /api/orgs/:accountId/lens/foundations-and-projects.
+ * Backs the Org Lens "Foundations and Projects" section on /org/overview.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class OrgLensFoundationsService {
+ private readonly http = inject(HttpClient);
+
+ public getFoundationsAndProjects(accountId: string): Observable {
+ return this.http.get(`/api/orgs/${encodeURIComponent(accountId)}/lens/foundations-and-projects`);
+ }
+}
diff --git a/apps/lfx-one/src/server/controllers/analytics.controller.ts b/apps/lfx-one/src/server/controllers/analytics.controller.ts
index 9dffe5864..ab50cb610 100644
--- a/apps/lfx-one/src/server/controllers/analytics.controller.ts
+++ b/apps/lfx-one/src/server/controllers/analytics.controller.ts
@@ -6,6 +6,7 @@ import { NextFunction, Request, Response } from 'express';
import { AuthenticationError, ServiceValidationError } from '../errors';
import { assertHealthMetricsRange, getStringQueryParam, parseEntityType } from '../helpers/validation.helper';
import { logger } from '../services/logger.service';
+import { OrgInvolvementService } from '../services/org-involvement.service';
import { OrganizationService } from '../services/organization.service';
import { ProjectService } from '../services/project.service';
import { UserService } from '../services/user.service';
@@ -24,11 +25,13 @@ const NAME_MAX_LENGTH = 200;
export class AnalyticsController {
private readonly userService: UserService;
private readonly organizationService: OrganizationService;
+ private readonly orgInvolvementService: OrgInvolvementService;
private readonly projectService: ProjectService;
public constructor() {
this.userService = new UserService();
this.organizationService = new OrganizationService();
+ this.orgInvolvementService = new OrgInvolvementService();
this.projectService = new ProjectService();
}
@@ -1751,6 +1754,196 @@ export class AnalyticsController {
}
}
+ // Organization Involvement Endpoints (cross-foundation, accountId only)
+
+ /**
+ * GET /api/analytics/org-foundation-coverage
+ * Get foundation coverage for an organization across all LF foundations
+ * Query params: accountId (required)
+ */
+ public async orgFoundationCoverage(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_foundation_coverage');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_foundation_coverage',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getFoundationCoverage(accountId);
+
+ logger.success(req, 'get_org_foundation_coverage', startTime, {
+ account_id: accountId,
+ foundation_count: response.foundationCount,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/analytics/org-involvement-contributors-monthly
+ * Get cross-foundation active contributors monthly for an organization
+ * Query params: accountId (required)
+ */
+ public async orgContributorsMonthly(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_involvement_contributors_monthly');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_involvement_contributors_monthly',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getContributorsMonthly(accountId);
+
+ logger.success(req, 'get_org_involvement_contributors_monthly', startTime, {
+ account_id: accountId,
+ total_active_contributors: response.totalActiveContributors,
+ monthly_data_points: response.monthlyData.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/analytics/org-involvement-maintainers-monthly
+ * Get cross-foundation maintainers monthly for an organization
+ * Query params: accountId (required)
+ */
+ public async orgMaintainersMonthly(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_involvement_maintainers_monthly');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_involvement_maintainers_monthly',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getMaintainersMonthly(accountId);
+
+ logger.success(req, 'get_org_involvement_maintainers_monthly', startTime, {
+ account_id: accountId,
+ total_maintainers: response.totalMaintainersYearly,
+ total_projects: response.totalProjectsYearly,
+ monthly_data_points: response.monthlyData.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/analytics/org-involvement-event-attendance-monthly
+ * Get cross-foundation event attendance monthly for an organization
+ * Query params: accountId (required)
+ */
+ public async orgEventAttendanceMonthly(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_involvement_event_attendance_monthly');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_involvement_event_attendance_monthly',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getEventAttendanceMonthly(accountId);
+
+ logger.success(req, 'get_org_involvement_event_attendance_monthly', startTime, {
+ account_id: accountId,
+ total_attended: response.totalAttended,
+ total_speakers: response.totalSpeakers,
+ monthly_data_points: response.monthlyLabels.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/analytics/org-involvement-certified-employees-monthly
+ * Get cross-foundation certified employees monthly for an organization
+ * Query params: accountId (required)
+ */
+ public async orgCertifiedEmployeesMonthly(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_involvement_certified_employees_monthly');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_involvement_certified_employees_monthly',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getCertifiedEmployeesMonthly(accountId);
+
+ logger.success(req, 'get_org_involvement_certified_employees_monthly', startTime, {
+ account_id: accountId,
+ total_certifications: response.totalCertifications,
+ total_certified_employees: response.totalCertifiedEmployees,
+ monthly_data_points: response.monthlyData.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/analytics/org-involvement-training-enrollments
+ * Get cross-foundation training enrollments YTD for an organization
+ * Query params: accountId (required)
+ */
+ public async orgTrainingEnrollments(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_involvement_training_enrollments');
+
+ try {
+ const accountId = getStringQueryParam(req, 'accountId');
+
+ if (!accountId) {
+ throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', {
+ operation: 'get_org_involvement_training_enrollments',
+ });
+ }
+
+ const response = await this.orgInvolvementService.getTrainingEnrollments(accountId);
+
+ logger.success(req, 'get_org_involvement_training_enrollments', startTime, {
+ account_id: accountId,
+ total_enrollments: response.totalEnrollments,
+ daily_data_points: response.dailyData.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
// Marketing Analytics Endpoints
// All marketing endpoints (Web Activities, Email CTR, Social Reach, Social Media, and the
// ED dashboard KPIs) use `foundationSlug` — underlying Snowflake Platinum views key on a
@@ -2602,6 +2795,31 @@ export class AnalyticsController {
}
}
+ /**
+ * GET /api/analytics/org-lens-account-context
+ * Resolve LFX One Org Lens display context for a set of Salesforce
+ * accounts — one denormalised row per account_id with cdev mapping
+ * and highest active corporate membership tier.
+ * Query params: accountIds (required) - Comma-separated Salesforce account IDs (max 50)
+ */
+ public async getOrgLensAccountContext(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_org_lens_account_context');
+
+ try {
+ const accountIds = this.parseAccountIdsParam(req, 'get_org_lens_account_context');
+ const response = await this.organizationService.getOrgLensAccountContext(accountIds);
+
+ logger.success(req, 'get_org_lens_account_context', startTime, {
+ requested_count: accountIds.length,
+ resolved_count: response.length,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
/**
* Parse and validate a comma-separated slugs query parameter.
* @throws ServiceValidationError if the parameter is missing, empty, exceeds max count, or has invalid format
@@ -2646,4 +2864,43 @@ export class AnalyticsController {
return slugs;
}
+
+ /**
+ * Parse and validate the `accountIds` query parameter (comma-separated
+ * Salesforce account IDs). De-duplicates, enforces a 50-id ceiling, and
+ * checks each id matches the Salesforce 15/18-char alphanumeric format.
+ */
+ private parseAccountIdsParam(req: Request, operation: string): string[] {
+ const raw = getStringQueryParam(req, 'accountIds');
+ if (!raw) {
+ throw ServiceValidationError.forField('accountIds', 'accountIds query parameter is required', { operation });
+ }
+
+ const ids = [
+ ...new Set(
+ raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ ),
+ ];
+
+ if (ids.length === 0) {
+ throw ServiceValidationError.forField('accountIds', 'At least one accountId is required', { operation });
+ }
+
+ const MAX_ACCOUNT_IDS = 50;
+ if (ids.length > MAX_ACCOUNT_IDS) {
+ throw ServiceValidationError.forField('accountIds', `Maximum of ${MAX_ACCOUNT_IDS} accountIds allowed per request`, { operation });
+ }
+
+ const SALESFORCE_ID_PATTERN = /^[A-Za-z0-9]{15,18}$/;
+ for (const id of ids) {
+ if (!SALESFORCE_ID_PATTERN.test(id)) {
+ throw ServiceValidationError.forField('accountIds', `Invalid Salesforce accountId format: ${id}`, { operation });
+ }
+ }
+
+ return ids;
+ }
}
diff --git a/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts b/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts
new file mode 100644
index 000000000..f91e05d45
--- /dev/null
+++ b/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts
@@ -0,0 +1,65 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { NextFunction, Request, Response } from 'express';
+
+import { ServiceValidationError } from '../errors';
+import { logger } from '../services/logger.service';
+import { OrgLensFoundationsService } from '../services/org-lens-foundations.service';
+
+/** Salesforce account ID pattern (15 or 18 chars, starts with 001). */
+const ACCOUNT_ID_PATTERN = /^001[A-Za-z0-9]{12,15}$/;
+
+/**
+ * Controller for the Org Lens Foundations and Projects section.
+ * Owns the HTTP boundary (validation, lifecycle logging, error
+ * propagation) for the OrgLensFoundationsService.
+ */
+export class OrgLensFoundationsController {
+ private readonly service: OrgLensFoundationsService;
+
+ public constructor() {
+ this.service = new OrgLensFoundationsService();
+ }
+
+ /**
+ * GET /api/orgs/:accountId/lens/foundations-and-projects
+ */
+ public async getFoundationsAndProjects(req: Request, res: Response, next: NextFunction): Promise {
+ const accountId = req.params['accountId'];
+ const startTime = logger.startOperation(req, 'get_org_lens_foundations_and_projects', {
+ account_id: accountId,
+ });
+
+ try {
+ if (!accountId || typeof accountId !== 'string') {
+ throw ServiceValidationError.forField('accountId', 'accountId path parameter is required', {
+ operation: 'get_org_lens_foundations_and_projects',
+ });
+ }
+
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) {
+ throw ServiceValidationError.forField('accountId', 'Invalid Salesforce accountId format', {
+ operation: 'get_org_lens_foundations_and_projects',
+ });
+ }
+
+ const response = await this.service.getFoundationsAndProjects(accountId);
+
+ const projectCountTotal = response.rows.reduce((sum, row) => sum + row.projects.length, 0);
+
+ logger.success(req, 'get_org_lens_foundations_and_projects', startTime, {
+ account_id: accountId,
+ row_count: response.rows.length,
+ project_count_total: projectCountTotal,
+ });
+
+ // No PII or tokens in logs. account_id is already a Salesforce
+ // opaque ID, safe.
+ res.setHeader('Cache-Control', 'no-store');
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+}
diff --git a/apps/lfx-one/src/server/routes/analytics.route.ts b/apps/lfx-one/src/server/routes/analytics.route.ts
index ace758fc1..b2ff79a22 100644
--- a/apps/lfx-one/src/server/routes/analytics.route.ts
+++ b/apps/lfx-one/src/server/routes/analytics.route.ts
@@ -129,6 +129,14 @@ router.get('/org-training-enrollments-distribution', (req, res, next) => analyti
router.get('/org-certified-employees-monthly', (req, res, next) => analyticsController.getOrgCertifiedEmployeesMonthly(req, res, next));
router.get('/org-certified-employees-distribution', (req, res, next) => analyticsController.getOrgCertifiedEmployeesDistribution(req, res, next));
+// Organization involvement endpoints (cross-foundation, accountId only — org overview page)
+router.get('/org-foundation-coverage', (req, res, next) => analyticsController.orgFoundationCoverage(req, res, next));
+router.get('/org-involvement-contributors-monthly', (req, res, next) => analyticsController.orgContributorsMonthly(req, res, next));
+router.get('/org-involvement-maintainers-monthly', (req, res, next) => analyticsController.orgMaintainersMonthly(req, res, next));
+router.get('/org-involvement-event-attendance-monthly', (req, res, next) => analyticsController.orgEventAttendanceMonthly(req, res, next));
+router.get('/org-involvement-certified-employees-monthly', (req, res, next) => analyticsController.orgCertifiedEmployeesMonthly(req, res, next));
+router.get('/org-involvement-training-enrollments', (req, res, next) => analyticsController.orgTrainingEnrollments(req, res, next));
+
// Web activities summary endpoint (marketing dashboard)
router.get('/web-activities-summary', (req, res, next) => analyticsController.getWebActivitiesSummary(req, res, next));
@@ -181,4 +189,7 @@ router.get('/marketing-attribution', (req, res, next) => analyticsController.get
// Multi-foundation summary endpoint (multi-foundation dashboard)
router.get('/multi-foundation-summary', (req, res, next) => analyticsController.getMultiFoundationSummary(req, res, next));
+// Org Lens — bootstrap account context (display attrs + cdev mapping + tier)
+router.get('/org-lens-account-context', (req, res, next) => analyticsController.getOrgLensAccountContext(req, res, next));
+
export default router;
diff --git a/apps/lfx-one/src/server/routes/orgs.route.ts b/apps/lfx-one/src/server/routes/orgs.route.ts
new file mode 100644
index 000000000..762b54ba5
--- /dev/null
+++ b/apps/lfx-one/src/server/routes/orgs.route.ts
@@ -0,0 +1,15 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Router } from 'express';
+
+import { OrgLensFoundationsController } from '../controllers/org-lens-foundations.controller';
+
+const router = Router();
+
+const orgLensFoundationsController = new OrgLensFoundationsController();
+
+// GET /api/orgs/:accountId/lens/foundations-and-projects
+router.get('/:accountId/lens/foundations-and-projects', (req, res, next) => orgLensFoundationsController.getFoundationsAndProjects(req, res, next));
+
+export default router;
diff --git a/apps/lfx-one/src/server/server.ts b/apps/lfx-one/src/server/server.ts
index f7a693454..a88488d45 100644
--- a/apps/lfx-one/src/server/server.ts
+++ b/apps/lfx-one/src/server/server.ts
@@ -30,6 +30,7 @@ import mailingListsRouter from './routes/mailing-lists.route';
import meetingsRouter from './routes/meetings.route';
import navigationRouter from './routes/navigation.route';
import organizationsRouter from './routes/organizations.route';
+import orgsRouter from './routes/orgs.route';
import pastMeetingsRouter from './routes/past-meetings.route';
import personaRouter from './routes/persona.route';
import profileRouter from './routes/profile.route';
@@ -178,6 +179,7 @@ app.use('/api/committees', committeesRouter);
app.use('/api/mailing-lists', mailingListsRouter);
app.use('/api/meetings', meetingsRouter);
app.use('/api/organizations', organizationsRouter);
+app.use('/api/orgs', orgsRouter);
app.use('/api/past-meetings', pastMeetingsRouter);
app.use('/api/profile', profileRouter);
app.use('/api/search', searchRouter);
diff --git a/apps/lfx-one/src/server/services/org-involvement.service.ts b/apps/lfx-one/src/server/services/org-involvement.service.ts
new file mode 100644
index 000000000..0510eb196
--- /dev/null
+++ b/apps/lfx-one/src/server/services/org-involvement.service.ts
@@ -0,0 +1,277 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import {
+ OrgInvolvementCertifiedEmployeesMonthlyResponse,
+ OrgInvolvementContributorsMonthlyResponse,
+ OrgInvolvementEventAttendanceMonthlyResponse,
+ OrgFoundationCoverageResponse,
+ OrgInvolvementMaintainersMonthlyResponse,
+ OrgTrainingEnrollmentsResponse,
+} from '@lfx-one/shared';
+
+import { ResourceNotFoundError } from '../errors';
+import { SnowflakeService } from './snowflake.service';
+
+interface FoundationCoverageRow {
+ ACCOUNT_ID: string;
+ FOUNDATION_ID: string;
+ FOUNDATION_SLUG: string;
+ FOUNDATION_NAME: string;
+ FOUNDATION_COUNT: number;
+}
+
+interface ContributorsMonthlyRow {
+ ACCOUNT_ID: string;
+ ACCOUNT_NAME: string;
+ MONTH_START_DATE: Date;
+ UNIQUE_CONTRIBUTORS: number;
+ TOTAL_ACTIVE_CONTRIBUTORS: number;
+}
+
+interface MaintainersMonthlyRow {
+ ACCOUNT_ID: string;
+ ACCOUNT_NAME: string;
+ METRIC_MONTH: Date;
+ ACTIVE_MAINTAINERS: number;
+ ACTIVE_PROJECTS: number;
+ TOTAL_MAINTAINERS_YEARLY: number;
+ TOTAL_PROJECTS_YEARLY: number;
+}
+
+interface EventAttendanceMonthlyRow {
+ ACCOUNT_ID: string;
+ ACCOUNT_NAME: string;
+ MONTH_START_DATE: Date;
+ REGISTRATION_COUNT: number;
+ ATTENDED_COUNT: number;
+ SPEAKER_COUNT: number;
+ TOTAL_REGISTRATIONS: number;
+ TOTAL_ATTENDED: number;
+ TOTAL_SPEAKERS: number;
+}
+
+interface CertifiedEmployeesMonthlyRow {
+ ACCOUNT_ID: string;
+ MONTH_START_DATE: Date;
+ MONTHLY_CERTIFICATIONS: number;
+ MONTHLY_CERTIFIED_EMPLOYEES: number;
+ TOTAL_CERTIFICATIONS: number;
+ TOTAL_CERTIFIED_EMPLOYEES: number;
+}
+
+interface TrainingEnrollmentRow {
+ ACCOUNT_ID: string;
+ ENROLLMENT_DATE: string;
+ DAILY_COUNT: number;
+ CUMULATIVE_COUNT: number;
+ TOTAL_ENROLLMENTS: number;
+}
+
+/**
+ * Service for cross-foundation organization involvement analytics.
+ * Queries the org_* platinum tables (account-level, no foundation filter).
+ */
+export class OrgInvolvementService {
+ private snowflakeService: SnowflakeService;
+
+ public constructor() {
+ this.snowflakeService = SnowflakeService.getInstance();
+ }
+
+ public async getFoundationCoverage(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ FOUNDATION_ID,
+ FOUNDATION_SLUG,
+ FOUNDATION_NAME,
+ FOUNDATION_COUNT
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_FOUNDATION_COVERAGE
+ WHERE ACCOUNT_ID = ?
+ ORDER BY FOUNDATION_NAME ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ throw new ResourceNotFoundError('Foundation coverage data', accountId, {
+ operation: 'get_foundation_coverage',
+ });
+ }
+
+ return {
+ accountId: result.rows[0].ACCOUNT_ID,
+ foundationCount: result.rows[0].FOUNDATION_COUNT || 0,
+ foundations: result.rows.map((row) => ({
+ foundationId: row.FOUNDATION_ID,
+ foundationSlug: row.FOUNDATION_SLUG,
+ foundationName: row.FOUNDATION_NAME,
+ })),
+ };
+ }
+
+ public async getContributorsMonthly(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ MONTH_START_DATE,
+ UNIQUE_CONTRIBUTORS,
+ TOTAL_ACTIVE_CONTRIBUTORS
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_CONTRIBUTORS_MONTHLY
+ WHERE ACCOUNT_ID = ?
+ ORDER BY MONTH_START_DATE ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ throw new ResourceNotFoundError('Contributors monthly data', accountId, {
+ operation: 'get_contributors_monthly',
+ });
+ }
+
+ const firstRow = result.rows[0];
+
+ return {
+ accountId: firstRow.ACCOUNT_ID,
+ totalActiveContributors: firstRow.TOTAL_ACTIVE_CONTRIBUTORS || 0,
+ monthlyData: result.rows.map((row) => row.UNIQUE_CONTRIBUTORS || 0),
+ monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })),
+ };
+ }
+
+ public async getMaintainersMonthly(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ ACCOUNT_NAME,
+ METRIC_MONTH,
+ ACTIVE_MAINTAINERS,
+ ACTIVE_PROJECTS,
+ TOTAL_MAINTAINERS_YEARLY,
+ TOTAL_PROJECTS_YEARLY
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_MAINTAINERS_MONTHLY
+ WHERE ACCOUNT_ID = ?
+ ORDER BY METRIC_MONTH ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ throw new ResourceNotFoundError('Maintainers monthly data', accountId, {
+ operation: 'get_maintainers_monthly',
+ });
+ }
+
+ const firstRow = result.rows[0];
+
+ return {
+ accountId: firstRow.ACCOUNT_ID,
+ accountName: firstRow.ACCOUNT_NAME,
+ totalMaintainersYearly: firstRow.TOTAL_MAINTAINERS_YEARLY || 0,
+ totalProjectsYearly: firstRow.TOTAL_PROJECTS_YEARLY || 0,
+ monthlyData: result.rows.map((row) => row.ACTIVE_MAINTAINERS || 0),
+ monthlyLabels: result.rows.map((row) => row.METRIC_MONTH.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })),
+ };
+ }
+
+ public async getEventAttendanceMonthly(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ ACCOUNT_NAME,
+ MONTH_START_DATE,
+ REGISTRATION_COUNT,
+ ATTENDED_COUNT,
+ SPEAKER_COUNT,
+ TOTAL_REGISTRATIONS,
+ TOTAL_ATTENDED,
+ TOTAL_SPEAKERS
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_EVENT_ATTENDANCE_MONTHLY
+ WHERE ACCOUNT_ID = ?
+ ORDER BY MONTH_START_DATE ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ throw new ResourceNotFoundError('Event attendance monthly data', accountId, {
+ operation: 'get_event_attendance_monthly',
+ });
+ }
+
+ const firstRow = result.rows[0];
+
+ return {
+ accountId: firstRow.ACCOUNT_ID,
+ accountName: firstRow.ACCOUNT_NAME,
+ totalAttended: firstRow.TOTAL_ATTENDED || 0,
+ totalSpeakers: firstRow.TOTAL_SPEAKERS || 0,
+ attendeesMonthlyData: result.rows.map((row) => row.ATTENDED_COUNT || 0),
+ speakersMonthlyData: result.rows.map((row) => row.SPEAKER_COUNT || 0),
+ monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })),
+ };
+ }
+
+ public async getCertifiedEmployeesMonthly(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ MONTH_START_DATE,
+ MONTHLY_CERTIFICATIONS,
+ MONTHLY_CERTIFIED_EMPLOYEES,
+ TOTAL_CERTIFICATIONS,
+ TOTAL_CERTIFIED_EMPLOYEES
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_CERTIFIED_EMPLOYEES_MONTHLY
+ WHERE ACCOUNT_ID = ?
+ ORDER BY MONTH_START_DATE ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ throw new ResourceNotFoundError('Certified employees monthly data', accountId, {
+ operation: 'get_certified_employees_monthly',
+ });
+ }
+
+ const firstRow = result.rows[0];
+
+ return {
+ accountId: firstRow.ACCOUNT_ID,
+ totalCertifications: firstRow.TOTAL_CERTIFICATIONS || 0,
+ totalCertifiedEmployees: firstRow.TOTAL_CERTIFIED_EMPLOYEES || 0,
+ monthlyData: result.rows.map((row) => row.MONTHLY_CERTIFICATIONS || 0),
+ monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })),
+ };
+ }
+
+ public async getTrainingEnrollments(accountId: string): Promise {
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ ENROLLMENT_DATE,
+ DAILY_COUNT,
+ CUMULATIVE_COUNT,
+ TOTAL_ENROLLMENTS
+ FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_TRAINING_ENROLLMENTS
+ WHERE ACCOUNT_ID = ?
+ ORDER BY ENROLLMENT_DATE ASC
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ const totalEnrollments = result.rows.length > 0 ? result.rows[result.rows.length - 1].TOTAL_ENROLLMENTS || 0 : 0;
+
+ return {
+ accountId,
+ totalEnrollments,
+ dailyData: result.rows.map((row) => ({
+ date: row.ENROLLMENT_DATE,
+ count: row.DAILY_COUNT,
+ cumulativeCount: row.CUMULATIVE_COUNT,
+ })),
+ };
+ }
+}
diff --git a/apps/lfx-one/src/server/services/org-lens-foundations.service.ts b/apps/lfx-one/src/server/services/org-lens-foundations.service.ts
new file mode 100644
index 000000000..d13205842
--- /dev/null
+++ b/apps/lfx-one/src/server/services/org-lens-foundations.service.ts
@@ -0,0 +1,301 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import type {
+ GovernanceParticipationBucket,
+ MembershipTierClass,
+ OrgLensFoundationProject,
+ OrgLensFoundationRow,
+ OrgLensFoundationsAndProjectsResponse,
+ OrgLensFoundationsStatStrip,
+ OrgLensRowKind,
+ OrgRoleBadge,
+ ProjectInfluenceBucket,
+ VotingStatusBadge,
+} from '@lfx-one/shared/interfaces';
+
+import { SnowflakeService } from './snowflake.service';
+
+/**
+ * Raw row shape returned by the joined query (foundations rollup LEFT
+ * JOIN per-project detail on (account_id, foundation_id)). One row per
+ * (account_id, foundation_id, project_id); foundations with zero
+ * involved projects emit a single row with all PROJECT_* columns null.
+ * Sentinel foundation_id '__outside_lf__' identifies the Outside-LF
+ * umbrella row.
+ *
+ * Kept private to this service — only cross-layer types live in
+ * @lfx-one/shared.
+ */
+interface RawRow {
+ ACCOUNT_ID: string;
+ ACCOUNT_NAME: string;
+ FOUNDATION_ID: string;
+ FOUNDATION_SLUG: string | null;
+ FOUNDATION_NAME: string;
+ FOUNDATION_LOGO_URL: string | null;
+ IS_MEMBER: boolean;
+ IS_OUTSIDE_LF: boolean;
+ MEMBERSHIP_TIER_CLASS: MembershipTierClass | null;
+ MEMBERSHIP_TIER_DISPLAY_NAME: string | null;
+ TIER_RANK: number | null;
+ PROJECT_COUNT_LF: number;
+ INFLUENCE_LEADING_COUNT: number;
+ INFLUENCE_CONTRIBUTING_COUNT: number;
+ INFLUENCE_PARTICIPATING_COUNT: number;
+ INFLUENCE_SILENT_COUNT: number;
+ BOARD_MEMBER_SEAT_COUNT: number;
+ COMMITTEE_MEMBER_SEAT_COUNT: number;
+ MEETINGS_THIS_WEEK_TOTAL: number;
+ MEETINGS_THIS_WEEK_BOARD: number;
+ MEETINGS_THIS_WEEK_TECHNICAL: number;
+ MEETINGS_THIS_WEEK_MARKETING: number;
+ MEETINGS_THIS_WEEK_WORKING_GROUP: number;
+ MEETINGS_THIS_WEEK_OTHER: number;
+ GOVERNANCE_ATTENDANCE_PCT: number | null;
+ GOVERNANCE_PARTICIPATION_BUCKET: GovernanceParticipationBucket | null;
+ ORG_ROLE: OrgRoleBadge;
+ VOTING_STATUS: VotingStatusBadge;
+ ROW_KIND: OrgLensRowKind;
+ PROJECT_ID: string | null;
+ PROJECT_SLUG: string | null;
+ PROJECT_NAME: string | null;
+ PROJECT_IS_LF_PROJECT: boolean | null;
+ PROJECT_INFLUENCE_MAX_BUCKET: ProjectInfluenceBucket | null;
+ PROJECT_MAINTAINERS_COUNT: number | null;
+ PROJECT_CONTRIBUTORS_COUNT: number | null;
+ PROJECT_COLLABORATORS_COUNT: number | null;
+ PROJECT_COMMITS_COUNT: number | null;
+}
+
+/**
+ * Service for the Org Lens "Foundations and Projects" section.
+ *
+ * Reads from two pre-aggregated upstream tables:
+ * - the foundations rollup — one row per (account_id, foundation_id)
+ * - the per-project detail — one row per (account_id, foundation_id, project_id)
+ *
+ * Single Snowflake query per render.
+ * Returns an empty envelope (200 + empty rows) for orgs with zero
+ * engagement — NEVER a 404.
+ */
+export class OrgLensFoundationsService {
+ private snowflakeService: SnowflakeService;
+
+ public constructor() {
+ this.snowflakeService = SnowflakeService.getInstance();
+ }
+
+ public async getFoundationsAndProjects(accountId: string): Promise {
+ // Single LEFT JOIN against the two pre-aggregated rollups. ORDER BY is
+ // CASE-guarded so `project_count_lf` only sorts non-member LF rows and
+ // NEVER member rows (which sort by tier_rank then foundation_name).
+ // Inner projects array is sorted by commits DESC.
+ const query = `
+ SELECT
+ f.ACCOUNT_ID,
+ f.ACCOUNT_NAME,
+ f.FOUNDATION_ID,
+ f.FOUNDATION_SLUG,
+ f.FOUNDATION_NAME,
+ f.FOUNDATION_LOGO_URL,
+ f.IS_MEMBER,
+ f.IS_OUTSIDE_LF,
+ f.MEMBERSHIP_TIER_CLASS,
+ f.MEMBERSHIP_TIER_DISPLAY_NAME,
+ f.TIER_RANK,
+ f.PROJECT_COUNT_LF,
+ f.INFLUENCE_LEADING_COUNT,
+ f.INFLUENCE_CONTRIBUTING_COUNT,
+ f.INFLUENCE_PARTICIPATING_COUNT,
+ f.INFLUENCE_SILENT_COUNT,
+ f.BOARD_MEMBER_SEAT_COUNT,
+ f.COMMITTEE_MEMBER_SEAT_COUNT,
+ f.MEETINGS_THIS_WEEK_TOTAL,
+ f.MEETINGS_THIS_WEEK_BOARD,
+ f.MEETINGS_THIS_WEEK_TECHNICAL,
+ f.MEETINGS_THIS_WEEK_MARKETING,
+ f.MEETINGS_THIS_WEEK_WORKING_GROUP,
+ f.MEETINGS_THIS_WEEK_OTHER,
+ f.GOVERNANCE_ATTENDANCE_PCT,
+ f.GOVERNANCE_PARTICIPATION_BUCKET,
+ f.ORG_ROLE,
+ f.VOTING_STATUS,
+ f.ROW_KIND,
+ p.PROJECT_ID AS PROJECT_ID,
+ p.PROJECT_SLUG AS PROJECT_SLUG,
+ p.PROJECT_NAME AS PROJECT_NAME,
+ p.IS_LF_PROJECT AS PROJECT_IS_LF_PROJECT,
+ p.INFLUENCE_MAX_BUCKET AS PROJECT_INFLUENCE_MAX_BUCKET,
+ p.MAINTAINERS_COUNT AS PROJECT_MAINTAINERS_COUNT,
+ p.CONTRIBUTORS_COUNT AS PROJECT_CONTRIBUTORS_COUNT,
+ p.COLLABORATORS_COUNT AS PROJECT_COLLABORATORS_COUNT,
+ p.COMMITS_COUNT AS PROJECT_COMMITS_COUNT
+ FROM ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_FOUNDATIONS_AND_PROJECTS f
+ LEFT JOIN ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_FOUNDATION_PROJECTS_DETAIL p
+ ON p.ACCOUNT_ID = f.ACCOUNT_ID
+ AND p.FOUNDATION_ID = f.FOUNDATION_ID
+ WHERE f.ACCOUNT_ID = ?
+ ORDER BY
+ CASE WHEN f.IS_OUTSIDE_LF THEN 2 WHEN f.IS_MEMBER THEN 0 ELSE 1 END,
+ CASE WHEN f.IS_MEMBER THEN f.TIER_RANK END ASC NULLS LAST,
+ CASE WHEN NOT f.IS_MEMBER AND NOT f.IS_OUTSIDE_LF THEN f.PROJECT_COUNT_LF END DESC NULLS LAST,
+ f.FOUNDATION_NAME ASC,
+ p.COMMITS_COUNT DESC NULLS LAST
+ `;
+
+ const result = await this.snowflakeService.execute(query, [accountId]);
+
+ if (result.rows.length === 0) {
+ return this.emptyResponse(accountId);
+ }
+
+ return this.shapeResponse(accountId, result.rows);
+ }
+
+ private shapeResponse(accountId: string, rawRows: RawRow[]): OrgLensFoundationsAndProjectsResponse {
+ const accountName = rawRows[0].ACCOUNT_NAME ?? 'Unknown Account';
+
+ // Group rows by foundation_id; the first raw row per foundation_id
+ // carries the foundation-level columns. SQL ORDER BY preserves the
+ // foundation sort + commits DESC inside each foundation.
+ const rowsByFoundation = new Map();
+
+ // Stat-strip running totals.
+ const tierBreakdown: Partial> = {};
+ let foundationsTotal = 0;
+ let projectsLeading = 0;
+ let projectsContributing = 0;
+ let projectsParticipating = 0;
+ let projectsSilent = 0;
+ let boardMembers = 0;
+ let committeeMembers = 0;
+ let mtwBoard = 0;
+ let mtwTechnical = 0;
+ let mtwMarketing = 0;
+ let mtwWorkingGroup = 0;
+ let mtwOther = 0;
+
+ for (const raw of rawRows) {
+ let row = rowsByFoundation.get(raw.FOUNDATION_ID);
+ if (!row) {
+ // Normalize the Outside-LF umbrella row's slug at the wire boundary.
+ // dbt emits FOUNDATION_ID='__outside_lf__' and FOUNDATION_SLUG also
+ // equal to '__outside_lf__' (literal sentinel string, NOT null) for
+ // the umbrella row. The wire value MUST be the kebab-case slug
+ // 'outside-lf'. We force this for any row_kind='outside_lf'
+ // regardless of what dbt sends — the sentinel is an internal
+ // implementation detail and MUST NOT leak into testids, telemetry
+ // payloads, or future routing keys.
+ const foundationSlug = raw.ROW_KIND === 'outside_lf' ? 'outside-lf' : (raw.FOUNDATION_SLUG ?? raw.FOUNDATION_ID);
+ row = {
+ foundationId: raw.FOUNDATION_ID,
+ foundationSlug,
+ foundationName: raw.FOUNDATION_NAME,
+ foundationLogoUrl: raw.FOUNDATION_LOGO_URL,
+ rowKind: raw.ROW_KIND,
+ membershipTierClass: raw.MEMBERSHIP_TIER_CLASS,
+ membershipTierDisplayName: raw.MEMBERSHIP_TIER_DISPLAY_NAME,
+ projectCount: raw.PROJECT_COUNT_LF ?? 0,
+ badges: {
+ orgRole: raw.ORG_ROLE,
+ votingStatus: raw.VOTING_STATUS,
+ // Outside LF emits NULL bucket → render em-dash.
+ governanceParticipation: raw.GOVERNANCE_PARTICIPATION_BUCKET ?? '—',
+ governanceAttendancePct: raw.GOVERNANCE_ATTENDANCE_PCT,
+ },
+ projects: [],
+ };
+ rowsByFoundation.set(raw.FOUNDATION_ID, row);
+
+ // Foundations tile: count member foundations only.
+ if (row.rowKind === 'member' && row.membershipTierClass) {
+ foundationsTotal += 1;
+ tierBreakdown[row.membershipTierClass] = (tierBreakdown[row.membershipTierClass] ?? 0) + 1;
+ }
+
+ // Governance + meetings tiles: LF foundations only.
+ // Outside-LF emits zero from dbt, but skip to be explicit.
+ if (row.rowKind !== 'outside_lf') {
+ boardMembers += raw.BOARD_MEMBER_SEAT_COUNT ?? 0;
+ committeeMembers += raw.COMMITTEE_MEMBER_SEAT_COUNT ?? 0;
+ mtwBoard += raw.MEETINGS_THIS_WEEK_BOARD ?? 0;
+ mtwTechnical += raw.MEETINGS_THIS_WEEK_TECHNICAL ?? 0;
+ mtwMarketing += raw.MEETINGS_THIS_WEEK_MARKETING ?? 0;
+ mtwWorkingGroup += raw.MEETINGS_THIS_WEEK_WORKING_GROUP ?? 0;
+ mtwOther += raw.MEETINGS_THIS_WEEK_OTHER ?? 0;
+ }
+ }
+
+ if (raw.PROJECT_ID && raw.PROJECT_INFLUENCE_MAX_BUCKET) {
+ const project: OrgLensFoundationProject = {
+ projectId: raw.PROJECT_ID,
+ projectSlug: raw.PROJECT_SLUG ?? raw.PROJECT_ID,
+ projectName: raw.PROJECT_NAME ?? raw.PROJECT_ID,
+ isLfProject: raw.PROJECT_IS_LF_PROJECT === true,
+ influence: raw.PROJECT_INFLUENCE_MAX_BUCKET,
+ maintainers: raw.PROJECT_MAINTAINERS_COUNT ?? 0,
+ contributors: raw.PROJECT_CONTRIBUTORS_COUNT ?? 0,
+ collaborators: raw.PROJECT_COLLABORATORS_COUNT ?? 0,
+ commits: raw.PROJECT_COMMITS_COUNT ?? 0,
+ };
+ row.projects.push(project);
+
+ // Projects tile: count every project on every row (including
+ // Outside-LF) per the influence bucket.
+ switch (project.influence) {
+ case 'Leading':
+ projectsLeading += 1;
+ break;
+ case 'Contributing':
+ projectsContributing += 1;
+ break;
+ case 'Participating':
+ projectsParticipating += 1;
+ break;
+ case 'Silent':
+ projectsSilent += 1;
+ break;
+ }
+ }
+ }
+
+ const rows = Array.from(rowsByFoundation.values());
+
+ const statStrip: OrgLensFoundationsStatStrip = {
+ foundations: { total: foundationsTotal, breakdown: tierBreakdown },
+ projects: {
+ total: projectsLeading + projectsContributing + projectsParticipating + projectsSilent,
+ leading: projectsLeading,
+ contributing: projectsContributing,
+ participating: projectsParticipating,
+ silent: projectsSilent,
+ },
+ governanceRoles: { total: boardMembers + committeeMembers, boardMembers, committeeMembers },
+ meetingsThisWeek: {
+ total: mtwBoard + mtwTechnical + mtwMarketing + mtwWorkingGroup + mtwOther,
+ board: mtwBoard,
+ technical: mtwTechnical,
+ marketing: mtwMarketing,
+ workingGroup: mtwWorkingGroup,
+ other: mtwOther,
+ },
+ };
+
+ return { accountId, accountName, statStrip, rows };
+ }
+
+ private emptyResponse(accountId: string): OrgLensFoundationsAndProjectsResponse {
+ return {
+ accountId,
+ accountName: '',
+ statStrip: {
+ foundations: { total: 0, breakdown: {} },
+ projects: { total: 0, leading: 0, contributing: 0, participating: 0, silent: 0 },
+ governanceRoles: { total: 0, boardMembers: 0, committeeMembers: 0 },
+ meetingsThisWeek: { total: 0, board: 0, technical: 0, marketing: 0, workingGroup: 0, other: 0 },
+ },
+ rows: [],
+ };
+ }
+}
diff --git a/apps/lfx-one/src/server/services/organization.service.ts b/apps/lfx-one/src/server/services/organization.service.ts
index 49dac8f49..c68a1620c 100644
--- a/apps/lfx-one/src/server/services/organization.service.ts
+++ b/apps/lfx-one/src/server/services/organization.service.ts
@@ -6,6 +6,8 @@
import {
CertifiedEmployeesMonthlyRow,
CertifiedEmployeesResponse,
+ OrgLensAccountContextResponse,
+ OrgLensAccountContextRow,
FoundationCompanyBusFactorResponse,
FoundationCompanyBusFactorRow,
MembershipTierResponse,
@@ -870,4 +872,62 @@ export class OrganizationService {
return { programs };
}
+
+ /**
+ * Resolve LFX One Org Lens account context for a set of Salesforce
+ * accounts (typically the user's persona-authorised organizations).
+ *
+ * Single-table read against
+ * platinum_lfx_one_org_lens_account_context — one denormalised
+ * platinum row per account_id with display attributes, Crowd.dev
+ * mapping, and the highest active corporate membership tier
+ * pre-joined inside dbt. No application-layer joins, no conglomerate
+ * expansion (the UI renders a flat list).
+ */
+ public async getOrgLensAccountContext(accountIds: string[]): Promise {
+ if (accountIds.length === 0) {
+ return [];
+ }
+
+ const placeholders = accountIds.map(() => '?').join(',');
+ const query = `
+ SELECT
+ ACCOUNT_ID,
+ ACCOUNT_NAME,
+ ACCOUNT_SLUG,
+ LOGO_URL,
+ CDEV_ORG_ID,
+ CDEV_ORG_NAME,
+ CDEV_ORG_LOGO,
+ IS_MEMBER,
+ MEMBER_ACCOUNT_TYPE,
+ MEMBERSHIP_ID,
+ MEMBERSHIP_PROJECT_ID,
+ MEMBERSHIP_PROJECT_NAME,
+ MEMBERSHIP_TIER_DISPLAY_NAME,
+ MEMBERSHIP_TIER_CLASS
+ FROM ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT
+ WHERE ACCOUNT_ID IN (${placeholders})
+ ORDER BY ACCOUNT_NAME
+ `;
+
+ const result = await this.snowflakeService.execute(query, accountIds);
+
+ return result.rows.map((row) => ({
+ accountId: row.ACCOUNT_ID,
+ accountName: row.ACCOUNT_NAME,
+ accountSlug: row.ACCOUNT_SLUG,
+ logoUrl: row.LOGO_URL,
+ cdevOrgId: row.CDEV_ORG_ID,
+ cdevOrgName: row.CDEV_ORG_NAME,
+ cdevOrgLogo: row.CDEV_ORG_LOGO,
+ isMember: row.IS_MEMBER,
+ memberAccountType: row.MEMBER_ACCOUNT_TYPE,
+ membershipId: row.MEMBERSHIP_ID,
+ membershipProjectId: row.MEMBERSHIP_PROJECT_ID,
+ membershipProjectName: row.MEMBERSHIP_PROJECT_NAME,
+ membershipTierDisplayName: row.MEMBERSHIP_TIER_DISPLAY_NAME,
+ membershipTierClass: row.MEMBERSHIP_TIER_CLASS,
+ }));
+ }
}
diff --git a/apps/lfx-one/src/server/services/persona-detection.service.ts b/apps/lfx-one/src/server/services/persona-detection.service.ts
index fbc3e4c58..bae823417 100644
--- a/apps/lfx-one/src/server/services/persona-detection.service.ts
+++ b/apps/lfx-one/src/server/services/persona-detection.service.ts
@@ -4,6 +4,7 @@
import {
AFFILIATED_PROJECT_UIDS_CACHE_TTL_MS,
DETECTION_SOURCE_MAP,
+ ORG_LENS_DEMO_SEED_ACCOUNTS,
PERSONA_PRIORITY,
PERSONAS_CACHE_TTL_MS,
ROOT_PROJECT_SLUG,
@@ -203,7 +204,7 @@ export class PersonaDetectionService {
personaProjects: {},
personas: ['contributor'],
projects: [],
- organizations: [],
+ organizations: this.withDemoSeedFallback(req, []),
error: detectionResponse.error.message,
};
}
@@ -215,7 +216,7 @@ export class PersonaDetectionService {
personaProjects: {},
personas: ['contributor'],
projects: [],
- organizations: [],
+ organizations: this.withDemoSeedFallback(req, []),
error: null,
};
}
@@ -235,11 +236,36 @@ export class PersonaDetectionService {
personaProjects,
personas,
projects,
- organizations: this.extractOrganizations(req, projects),
+ organizations: this.withDemoSeedFallback(req, this.extractOrganizations(req, projects)),
error: null,
};
}
+ /**
+ * Org Lens demo seed fallback.
+ *
+ * Until the upstream persona service returns user-scoped organizations
+ * directly (blocked on persona-service work), the only path that
+ * populates `organizations` today is `extractOrganizations`, which
+ * scrapes `board_member` detection extras. Users without board
+ * memberships — i.e. most engineering accounts on the dev cluster —
+ * would see an empty selector.
+ *
+ * Returns the demo seed (`ORG_LENS_DEMO_SEED_ACCOUNTS`) only when the
+ * real extraction produced nothing, so real board members still see
+ * their actual orgs first. Replace this fallback with a live source
+ * once the persona contract delivers organizations.
+ */
+ private withDemoSeedFallback(req: Request, accounts: Account[]): Account[] {
+ if (accounts.length > 0) {
+ return accounts;
+ }
+ logger.debug(req, 'extract_organizations', 'No detected orgs — returning Org Lens demo seed', {
+ seed_count: ORG_LENS_DEMO_SEED_ACCOUNTS.length,
+ });
+ return ORG_LENS_DEMO_SEED_ACCOUNTS.map((account) => ({ ...account }));
+ }
+
private async fetchAndResolveAffiliatedSlugs(req: Request, username: string, email: string): Promise {
const detectionResponse = await this.fetchPersonaDetections(req, username, email);
diff --git a/packages/shared/src/constants/accounts.constants.ts b/packages/shared/src/constants/accounts.constants.ts
index 51062d35b..2b88bb341 100644
--- a/packages/shared/src/constants/accounts.constants.ts
+++ b/packages/shared/src/constants/accounts.constants.ts
@@ -6,37 +6,24 @@ import type { Account } from '../interfaces/account.interface';
export const ACCOUNT_COOKIE_KEY = 'lfx-selected-account';
/**
- * Available accounts for board member dashboard
- * @description Predefined list of organizations with their account IDs
+ * Demo seed for the Org Lens feature branch. Used as a fallback in
+ * PersonaDetectionService when the upstream persona service doesn't yet
+ * return user-scoped { accountId, cdevOrgId } pairs (blocked on upstream
+ * persona-service work). Only identifier + display name live here —
+ * tier, slug, and logo are intentionally resolved from Snowflake via
+ * getOrgLensAccountContext, so the demo exercises the real enrichment
+ * path end-to-end.
+ *
+ * Remove (or replace with a live source) once the persona service
+ * delivers organizations directly.
*/
-export const ACCOUNTS: Account[] = [
- { accountId: '0012M00002kMtLDQA0', accountName: 'Axcelis Technologies' },
- { accountId: '0014100000TdzqcAAB', accountName: 'Credit Suisse' },
- { accountId: '0012M00002GDRWBQA5', accountName: 'Ericsson Software Technology' },
- { accountId: '0014100000TdzJHAAZ', accountName: 'Fujitsu Limited' },
- { accountId: '0014100000TdzJQAAZ', accountName: 'GitLab Inc.' },
- { accountId: '0014100000Te02DAAR', accountName: 'Google LLC' },
- { accountId: '0014100000TdzABAAZ', accountName: 'Huawei Technologies Co., Ltd' },
- { accountId: '0014100000TdzA7AAJ', accountName: 'Intel Corporation' },
- { accountId: '0014100000TdzwSAAR', accountName: 'Meta Platforms, Inc.' },
- { accountId: '0014100000Te0OKAAZ', accountName: 'Microsoft Corporation' },
- { accountId: '0014100000Te0QfAAJ', accountName: 'NEC Corporation' },
- { accountId: '0014100000Te2MfAAJ', accountName: 'Oracle America Inc.' },
- { accountId: 'lflowfQUSzqglPDJtp', accountName: 'Panasonic Corporation' },
- { accountId: '0014100000Te2PpAAJ', accountName: 'Qualcomm, Inc.' },
+export const ORG_LENS_DEMO_SEED_ACCOUNTS: Account[] = [
+ { accountId: '0012M00002ND5yOQAT', accountName: 'Toyota Motor Corporation' },
+ { accountId: '0014100000kgVoqAAE', accountName: 'International Business Machines Corporation' },
{ accountId: '0014100000Te2QjAAJ', accountName: 'Red Hat, Inc.' },
- { accountId: '0012M00002KB7YYQA1', accountName: 'Redpoint Ventures' },
- { accountId: '0014100000Te0bvAAB', accountName: 'Renesas Electronics Corporation' },
- { accountId: '0014100000Te0dPAAR', accountName: 'Samsung Electronics Co. Ltd.' },
- { accountId: '0014100000Te0jpAAB', accountName: 'Sony Group Corporation' },
- { accountId: '0014100000Te2ovAAB', accountName: 'The Linux Foundation' },
+ { accountId: '0014100000TdzYmAAJ', accountName: 'Apptio' },
+ { accountId: '0012M00002ZLGHsQAP', accountName: 'IBM Watson Health' },
+ { accountId: '0012M000027Enn8QAC', accountName: 'Nordcloud, an IBM Company' },
+ { accountId: '0012M00002F2mObQAJ', accountName: 'Stackwatch Inc' },
+ { accountId: '0014100000Te2qeAAB', accountName: 'Turbonomic, an IBM Company' },
];
-
-/**
- * Default account for board member dashboard
- * @description The Linux Foundation is used as the default selection
- */
-export const DEFAULT_ACCOUNT: Account = {
- accountId: '0014100000Te2ovAAB',
- accountName: 'The Linux Foundation',
-};
diff --git a/packages/shared/src/constants/dashboard-metrics.constants.ts b/packages/shared/src/constants/dashboard-metrics.constants.ts
index e5d83c451..69e3d4140 100644
--- a/packages/shared/src/constants/dashboard-metrics.constants.ts
+++ b/packages/shared/src/constants/dashboard-metrics.constants.ts
@@ -318,6 +318,59 @@ export const PRIMARY_INVOLVEMENT_METRICS: DashboardMetricCard[] = [
},
];
+// ============================================
+// Org Overview Involvement Metrics (cross-foundation, non-clickable)
+// ============================================
+
+/**
+ * Engagement card configuration for the /org/overview involvement section.
+ * 6 cards aggregated across all LF foundations. Cards are NOT clickable — no drawerType.
+ */
+export const ORG_INVOLVEMENT_METRICS: DashboardMetricCard[] = [
+ {
+ title: 'Active Contributors',
+ icon: 'fa-light fa-code',
+ chartType: 'bar',
+ category: 'contributors',
+ testId: 'org-overview-involvement-card-active-contributors',
+ },
+ {
+ title: 'Maintainers',
+ icon: 'fa-light fa-user-check',
+ chartType: 'bar',
+ category: 'contributors',
+ testId: 'org-overview-involvement-card-maintainers',
+ },
+ {
+ title: 'Event Attendees',
+ icon: 'fa-light fa-user-group',
+ chartType: 'line',
+ category: 'events',
+ testId: 'org-overview-involvement-card-event-attendees',
+ },
+ {
+ title: 'Event Speakers',
+ icon: 'fa-light fa-award-simple',
+ chartType: 'line',
+ category: 'events',
+ testId: 'org-overview-involvement-card-event-speakers',
+ },
+ {
+ title: 'Certified Employees',
+ icon: 'fa-light fa-graduation-cap',
+ chartType: 'line',
+ category: 'education',
+ testId: 'org-overview-involvement-card-certified-employees',
+ },
+ {
+ title: 'Training Enrollments',
+ icon: 'fa-light fa-graduation-cap',
+ chartType: 'line',
+ category: 'education',
+ testId: 'org-overview-involvement-card-training-enrollments',
+ },
+];
+
// ============================================
// Marketing Overview Metrics (Executive Director)
// ============================================
diff --git a/packages/shared/src/interfaces/account.interface.ts b/packages/shared/src/interfaces/account.interface.ts
index 68c3b7dc7..4945b5812 100644
--- a/packages/shared/src/interfaces/account.interface.ts
+++ b/packages/shared/src/interfaces/account.interface.ts
@@ -2,12 +2,21 @@
// SPDX-License-Identifier: MIT
/**
- * Account entity representing an organization in the system
- * @description Maps account ID to organization name for board member dashboard
+ * Account entity representing an organization in the LFX One Org Lens.
+ * Used by the org-selector and any header/badge that needs the org's
+ * display attributes.
*/
export interface Account {
- /** Unique account identifier */
+ /** Salesforce account_id — primary join key */
accountId: string;
/** Organization display name */
accountName: string;
+ /** Crowd.dev organization id — secondary identifier, mapped from accountId in Snowflake */
+ cdevOrgId?: string;
+ /** URL-friendly slug derived from the account name */
+ accountSlug?: string;
+ /** Logo URL for the organization */
+ logoUrl?: string;
+ /** Highest active corporate membership tier display name (e.g. "Platinum Membership"). NULL/empty → no badge. */
+ membershipTier?: string;
}
diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts
index 7d1ade301..9cdd30654 100644
--- a/packages/shared/src/interfaces/index.ts
+++ b/packages/shared/src/interfaces/index.ts
@@ -66,6 +66,9 @@ export * from './snowflake.interface';
// Account interfaces
export * from './account.interface';
+// Org Lens (per-account TLF membership tier + cdev org mapping) interfaces
+export * from './org-lens.interface';
+
// Mailing list interfaces
export * from './mailing-list.interface';
@@ -145,5 +148,8 @@ export * from './supabase.interface';
// Stat card interfaces
export * from './stat-card.interface';
+// Org involvement interfaces (cross-foundation org overview)
+export * from './org-involvement.interface';
+
// Changelog interfaces
export * from './changelog.interface';
diff --git a/packages/shared/src/interfaces/org-involvement.interface.ts b/packages/shared/src/interfaces/org-involvement.interface.ts
new file mode 100644
index 000000000..01d9671db
--- /dev/null
+++ b/packages/shared/src/interfaces/org-involvement.interface.ts
@@ -0,0 +1,90 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+/**
+ * Foundation entry in the org foundation coverage response
+ */
+export interface OrgFoundationCoverageFoundation {
+ foundationId: string;
+ foundationSlug: string;
+ foundationName: string;
+}
+
+/**
+ * GET /api/analytics/org-foundation-coverage
+ * How many LF foundations this organization is involved in
+ */
+export interface OrgFoundationCoverageResponse {
+ accountId: string;
+ foundationCount: number;
+ foundations: OrgFoundationCoverageFoundation[];
+}
+
+/**
+ * GET /api/analytics/org-contributors-monthly
+ * Cross-foundation active contributors aggregated monthly (12-month window)
+ */
+export interface OrgInvolvementContributorsMonthlyResponse {
+ accountId: string;
+ totalActiveContributors: number;
+ monthlyData: number[];
+ monthlyLabels: string[];
+}
+
+/**
+ * GET /api/analytics/org-maintainers-monthly
+ * Cross-foundation maintainers aggregated monthly (12-month window)
+ */
+export interface OrgInvolvementMaintainersMonthlyResponse {
+ accountId: string;
+ accountName: string;
+ totalMaintainersYearly: number;
+ totalProjectsYearly: number;
+ monthlyData: number[];
+ monthlyLabels: string[];
+}
+
+/**
+ * GET /api/analytics/org-event-attendance-monthly
+ * Cross-foundation event attendance aggregated monthly (12-month window)
+ */
+export interface OrgInvolvementEventAttendanceMonthlyResponse {
+ accountId: string;
+ accountName: string;
+ totalAttended: number;
+ totalSpeakers: number;
+ attendeesMonthlyData: number[];
+ speakersMonthlyData: number[];
+ monthlyLabels: string[];
+}
+
+/**
+ * GET /api/analytics/org-certified-employees-monthly
+ * Cross-foundation certified employees aggregated monthly (12-month window)
+ */
+export interface OrgInvolvementCertifiedEmployeesMonthlyResponse {
+ accountId: string;
+ totalCertifications: number;
+ totalCertifiedEmployees: number;
+ monthlyData: number[];
+ monthlyLabels: string[];
+}
+
+/**
+ * Daily data point for training enrollments
+ */
+export interface OrgTrainingEnrollmentDailyDataPoint {
+ date: string;
+ count: number;
+ cumulativeCount: number;
+}
+
+/**
+ * GET /api/analytics/org-training-enrollments
+ * Cross-foundation training enrollments YTD (daily grain)
+ */
+export interface OrgTrainingEnrollmentsResponse {
+ accountId: string;
+ totalEnrollments: number;
+ dailyData: OrgTrainingEnrollmentDailyDataPoint[];
+}
diff --git a/packages/shared/src/interfaces/org-lens.interface.ts b/packages/shared/src/interfaces/org-lens.interface.ts
new file mode 100644
index 000000000..cde69e582
--- /dev/null
+++ b/packages/shared/src/interfaces/org-lens.interface.ts
@@ -0,0 +1,158 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+/**
+ * LFX Org Lens membership tier classes, in descending tier order.
+ * Drives any tier-based ranking or filtering on the client side.
+ * The set of valid values must stay in sync with the canonical tier
+ * classes emitted by the upstream membership-tier table.
+ *
+ * Rank: Platinum(1) > Premier(2) > Founding(3) > Strategic(4) > Gold(5) >
+ * Steering(6) > Silver(7) > General(8) > Associate(9) > End User(10) >
+ * Academic(11) > Contributor(12) > Other(13)
+ *
+ * Migration from the prior 10-class ladder to the current 13-class one:
+ * - Sponsor is REMOVED. Existing 'Associate Sponsor' display labels
+ * reclassify to Associate via the upstream LIKE '%Associate%' arm.
+ * - Steering DEMOTED from rank 3 to rank 6 (now below Gold).
+ * - Founding, Strategic, End User, Contributor are NEW canonical classes.
+ */
+export type MembershipTierClass =
+ | 'Platinum'
+ | 'Premier'
+ | 'Founding'
+ | 'Strategic'
+ | 'Gold'
+ | 'Steering'
+ | 'Silver'
+ | 'General'
+ | 'Associate'
+ | 'End User'
+ | 'Academic'
+ | 'Contributor'
+ | 'Other';
+
+/**
+ * Raw row from ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT — the
+ * single denormalised platinum table that resolves a Salesforce
+ * account_id to the full Org Lens display context (account attributes,
+ * Crowd.dev mapping, highest active corporate membership tier).
+ */
+export interface OrgLensAccountContextRow {
+ ACCOUNT_ID: string;
+ ACCOUNT_NAME: string;
+ ACCOUNT_SLUG: string | null;
+ LOGO_URL: string | null;
+ CDEV_ORG_ID: string | null;
+ CDEV_ORG_NAME: string | null;
+ CDEV_ORG_LOGO: string | null;
+ IS_MEMBER: boolean;
+ MEMBER_ACCOUNT_TYPE: string | null;
+ MEMBERSHIP_ID: string | null;
+ MEMBERSHIP_PROJECT_ID: string | null;
+ MEMBERSHIP_PROJECT_NAME: string | null;
+ MEMBERSHIP_TIER_DISPLAY_NAME: string | null;
+ MEMBERSHIP_TIER_CLASS: MembershipTierClass | null;
+}
+
+export interface OrgLensAccountContextResponse {
+ accountId: string;
+ accountName: string;
+ accountSlug: string | null;
+ logoUrl: string | null;
+ cdevOrgId: string | null;
+ cdevOrgName: string | null;
+ cdevOrgLogo: string | null;
+ isMember: boolean;
+ memberAccountType: string | null;
+ membershipId: string | null;
+ membershipProjectId: string | null;
+ membershipProjectName: string | null;
+ membershipTierDisplayName: string | null;
+ membershipTierClass: MembershipTierClass | null;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Foundations and Projects section
+// ─────────────────────────────────────────────────────────────────────────
+
+export type OrgLensRowKind = 'member' | 'non_member' | 'outside_lf';
+export type OrgRoleBadge = 'Director' | 'Member' | 'Non-Member';
+export type VotingStatusBadge = 'Voting' | 'Observer' | '—';
+export type GovernanceParticipationBucket = 'Active' | 'Partial' | 'Inactive' | '—';
+export type ProjectInfluenceBucket = 'Leading' | 'Contributing' | 'Participating' | 'Silent';
+
+/**
+ * One inline-detail-table row per project the org is involved with under a foundation.
+ * Sorted by `commits` DESC on the wire.
+ */
+export interface OrgLensFoundationProject {
+ projectId: string;
+ projectSlug: string;
+ projectName: string;
+ isLfProject: boolean;
+ influence: ProjectInfluenceBucket;
+ maintainers: number;
+ contributors: number;
+ collaborators: number;
+ commits: number;
+}
+
+/**
+ * One row in the foundations table. Inline-detail data is pre-loaded
+ * inside `projects` so the caret toggle never triggers a fetch.
+ */
+export interface OrgLensFoundationRow {
+ foundationId: string;
+ foundationSlug: string;
+ foundationName: string;
+ foundationLogoUrl: string | null;
+ rowKind: OrgLensRowKind;
+ membershipTierClass: MembershipTierClass | null;
+ membershipTierDisplayName: string | null;
+ projectCount: number;
+ badges: {
+ orgRole: OrgRoleBadge;
+ votingStatus: VotingStatusBadge;
+ governanceParticipation: GovernanceParticipationBucket;
+ /** 0..1; null on outside-lf rows (no LF governance to participate in). */
+ governanceAttendancePct: number | null;
+ };
+ projects: OrgLensFoundationProject[];
+}
+
+export interface OrgLensFoundationsStatStrip {
+ foundations: {
+ total: number;
+ /** Zero buckets MUST be omitted on the wire. */
+ breakdown: Partial>;
+ };
+ projects: {
+ total: number;
+ leading: number;
+ contributing: number;
+ participating: number;
+ silent: number;
+ };
+ governanceRoles: {
+ total: number;
+ boardMembers: number;
+ committeeMembers: number;
+ };
+ meetingsThisWeek: {
+ total: number;
+ board: number;
+ technical: number;
+ marketing: number;
+ workingGroup: number;
+ other: number;
+ };
+}
+
+export interface OrgLensFoundationsAndProjectsResponse {
+ accountId: string;
+ accountName: string;
+ statStrip: OrgLensFoundationsStatStrip;
+ /** Render-ordered: member tier-ranked → non-member by project count → outside-lf last. */
+ rows: OrgLensFoundationRow[];
+}