Skip to content

Commit f130ddd

Browse files
jordaneclaude
andcommitted
feat(dashboard): add organization selector to board member dashboard
Add dynamic organization selection dropdown to board member dashboard that allows users to filter analytics data by different organizations. The dropdown displays 12 predefined organizations and persists the selection to localStorage. Key changes: - Add Account interface and constants with 12 organizations - Create AccountContextService to manage selected organization state - Add organization dropdown to board member dashboard header - Update analytics service methods to accept optional accountId parameter - Modify backend analytics controller to read accountId from query params - Fix reactive data flow using toObservable and RxJS operators - Update membership tier to use correct Snowflake column names (CURRENT_MEMBERSHIP_START_DATE, CURRENT_MEMBERSHIP_END_DATE) The implementation uses Angular signals for reactive state management, automatically refreshing all dashboard metrics when organization changes. Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jordan Evans <jevans@linuxfoundation.org>
1 parent 7a425e2 commit f130ddd

File tree

11 files changed

+240
-59
lines changed

11 files changed

+240
-59
lines changed

apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22
<!-- SPDX-License-Identifier: MIT -->
33

44
<div class="container mx-auto px-4 sm:px-6 lg:px-8" data-testid="dashboard-container">
5+
<!-- Organization Selector -->
6+
<div class="mb-6 flex items-center gap-4" data-testid="organization-selector">
7+
<label for="organization-select" class="text-sm font-semibold text-gray-700">Organization:</label>
8+
<lfx-select
9+
[form]="accountForm"
10+
control="selectedAccountId"
11+
[options]="availableAccounts()"
12+
optionLabel="accountName"
13+
optionValue="accountId"
14+
[filter]="true"
15+
filterPlaceholder="Search organizations..."
16+
placeholder="Select an organization"
17+
[showClear]="false"
18+
styleClass="min-w-[300px]"
19+
inputId="organization-select"
20+
data-testid="organization-select"
21+
(onChange)="handleAccountChange($event)" />
22+
</div>
23+
524
<!-- Dashboard Sections -->
625
<div class="flex flex-col gap-6" data-testid="dashboard-sections-grid">
726
<!-- Organization Involvement - Full Width -->
Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { Component } from '@angular/core';
4+
import { Component, computed, inject, Signal } from '@angular/core';
5+
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
6+
import { Account } from '@lfx-one/shared/interfaces';
7+
import { SelectComponent } from '../../../shared/components/select/select.component';
8+
import { AccountContextService } from '../../../shared/services/account-context.service';
59
import { FoundationHealthComponent } from '../components/foundation-health/foundation-health.component';
610
import { MyMeetingsComponent } from '../components/my-meetings/my-meetings.component';
711
import { OrganizationInvolvementComponent } from '../components/organization-involvement/organization-involvement.component';
812
import { PendingActionsComponent } from '../components/pending-actions/pending-actions.component';
913

1014
@Component({
1115
selector: 'lfx-board-member-dashboard',
12-
imports: [OrganizationInvolvementComponent, PendingActionsComponent, MyMeetingsComponent, FoundationHealthComponent],
16+
imports: [OrganizationInvolvementComponent, PendingActionsComponent, MyMeetingsComponent, FoundationHealthComponent, SelectComponent, ReactiveFormsModule],
1317
templateUrl: './board-member-dashboard.component.html',
1418
styleUrl: './board-member-dashboard.component.scss',
1519
})
16-
export class BoardMemberDashboardComponent {}
20+
export class BoardMemberDashboardComponent {
21+
private readonly accountContextService = inject(AccountContextService);
22+
23+
protected readonly accountForm = new FormGroup({
24+
selectedAccountId: new FormControl<string>(this.accountContextService.selectedAccount().accountId),
25+
});
26+
27+
protected readonly availableAccounts: Signal<Account[]> = computed(() => this.accountContextService.availableAccounts);
28+
29+
/**
30+
* Handle account selection change
31+
*/
32+
protected handleAccountChange(event: any): void {
33+
const selectedAccountId = event.value as string;
34+
const selectedAccount = this.accountContextService.availableAccounts.find((acc) => acc.accountId === selectedAccountId);
35+
if (selectedAccount) {
36+
this.accountContextService.setAccount(selectedAccount);
37+
}
38+
}
39+
}

apps/lfx-one/src/app/modules/dashboards/components/organization-involvement/organization-involvement.component.ts

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
import { CommonModule } from '@angular/common';
55
import { Component, computed, inject } from '@angular/core';
6-
import { toSignal } from '@angular/core/rxjs-interop';
6+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
7+
import { AccountContextService } from '@app/shared/services/account-context.service';
78
import { AnalyticsService } from '@app/shared/services/analytics.service';
89
import { ChartComponent } from '@components/chart/chart.component';
910
import { CONTRIBUTIONS_METRICS, IMPACT_METRICS, PRIMARY_INVOLVEMENT_METRICS } from '@lfx-one/shared/constants';
@@ -17,6 +18,7 @@ import {
1718
PrimaryInvolvementMetric,
1819
} from '@lfx-one/shared/interfaces';
1920
import { hexToRgba } from '@lfx-one/shared/utils';
21+
import { map, switchMap } from 'rxjs';
2022

2123
@Component({
2224
selector: 'lfx-organization-involvement',
@@ -27,22 +29,34 @@ import { hexToRgba } from '@lfx-one/shared/utils';
2729
})
2830
export class OrganizationInvolvementComponent {
2931
private readonly analyticsService = inject(AnalyticsService);
30-
private readonly organizationMaintainersData = toSignal(this.analyticsService.getOrganizationMaintainers(), {
31-
initialValue: {
32-
maintainers: 0,
33-
projects: 0,
34-
accountId: '',
35-
},
36-
});
37-
private readonly organizationContributorsData = toSignal(this.analyticsService.getOrganizationContributors(), {
38-
initialValue: {
39-
contributors: 0,
40-
accountId: '',
41-
accountName: '',
42-
projects: 0,
43-
},
44-
});
45-
private readonly membershipTierData = toSignal(this.analyticsService.getMembershipTier(), {
32+
private readonly accountContextService = inject(AccountContextService);
33+
34+
private readonly selectedAccountId$ = toObservable(this.accountContextService.selectedAccount).pipe(map((account) => account.accountId));
35+
36+
private readonly organizationMaintainersData = toSignal(
37+
this.selectedAccountId$.pipe(switchMap((accountId) => this.analyticsService.getOrganizationMaintainers(accountId))),
38+
{
39+
initialValue: {
40+
maintainers: 0,
41+
projects: 0,
42+
accountId: '',
43+
},
44+
}
45+
);
46+
47+
private readonly organizationContributorsData = toSignal(
48+
this.selectedAccountId$.pipe(switchMap((accountId) => this.analyticsService.getOrganizationContributors(accountId))),
49+
{
50+
initialValue: {
51+
contributors: 0,
52+
accountId: '',
53+
accountName: '',
54+
projects: 0,
55+
},
56+
}
57+
);
58+
59+
private readonly membershipTierData = toSignal(this.selectedAccountId$.pipe(switchMap((accountId) => this.analyticsService.getMembershipTier(accountId))), {
4660
initialValue: {
4761
tier: '',
4862
membershipStartDate: '',
@@ -52,22 +66,30 @@ export class OrganizationInvolvementComponent {
5266
accountId: '',
5367
},
5468
});
55-
private readonly eventAttendanceData = toSignal(this.analyticsService.getOrganizationEventAttendance(), {
56-
initialValue: {
57-
totalAttendees: 0,
58-
totalSpeakers: 0,
59-
totalEvents: 0,
60-
accountId: '',
61-
accountName: '',
62-
},
63-
});
64-
private readonly technicalCommitteeData = toSignal(this.analyticsService.getOrganizationTechnicalCommittee(), {
65-
initialValue: {
66-
totalRepresentatives: 0,
67-
totalProjects: 0,
68-
accountId: '',
69-
},
70-
});
69+
70+
private readonly eventAttendanceData = toSignal(
71+
this.selectedAccountId$.pipe(switchMap((accountId) => this.analyticsService.getOrganizationEventAttendance(accountId))),
72+
{
73+
initialValue: {
74+
totalAttendees: 0,
75+
totalSpeakers: 0,
76+
totalEvents: 0,
77+
accountId: '',
78+
accountName: '',
79+
},
80+
}
81+
);
82+
83+
private readonly technicalCommitteeData = toSignal(
84+
this.selectedAccountId$.pipe(switchMap((accountId) => this.analyticsService.getOrganizationTechnicalCommittee(accountId))),
85+
{
86+
initialValue: {
87+
totalRepresentatives: 0,
88+
totalProjects: 0,
89+
accountId: '',
90+
},
91+
}
92+
);
7193

7294
protected readonly isLoading = computed<boolean>(() => {
7395
const maintainersData = this.organizationMaintainersData();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Injectable, signal, WritableSignal } from '@angular/core';
5+
import { ACCOUNTS, DEFAULT_ACCOUNT } from '@lfx-one/shared/constants';
6+
import { Account } from '@lfx-one/shared/interfaces';
7+
8+
@Injectable({
9+
providedIn: 'root',
10+
})
11+
export class AccountContextService {
12+
private readonly storageKey = 'lfx-selected-account';
13+
public readonly selectedAccount: WritableSignal<Account>;
14+
public readonly availableAccounts: Account[] = ACCOUNTS;
15+
16+
public constructor() {
17+
const stored = this.loadStoredAccount();
18+
this.selectedAccount = signal<Account>(stored || DEFAULT_ACCOUNT);
19+
}
20+
21+
/**
22+
* Set the selected account and persist to storage
23+
*/
24+
public setAccount(account: Account): void {
25+
this.selectedAccount.set(account);
26+
this.persistAccount(account);
27+
}
28+
29+
/**
30+
* Get the currently selected account ID
31+
*/
32+
public getAccountId(): string {
33+
return this.selectedAccount().accountId;
34+
}
35+
36+
private persistAccount(account: Account): void {
37+
localStorage.setItem(this.storageKey, JSON.stringify(account));
38+
}
39+
40+
private loadStoredAccount(): Account | null {
41+
try {
42+
const stored = localStorage.getItem(this.storageKey);
43+
if (stored) {
44+
return JSON.parse(stored) as Account;
45+
}
46+
} catch {
47+
// Invalid data in localStorage, ignore
48+
}
49+
return null;
50+
}
51+
}

apps/lfx-one/src/app/shared/services/analytics.service.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ export class AnalyticsService {
9898

9999
/**
100100
* Get organization-level maintainer and project statistics
101+
* @param accountId - Optional account ID to filter by specific organization
101102
* @returns Observable of organization maintainers response
102103
*/
103-
public getOrganizationMaintainers(): Observable<OrganizationMaintainersResponse> {
104-
return this.http.get<OrganizationMaintainersResponse>('/api/analytics/organization-maintainers').pipe(
104+
public getOrganizationMaintainers(accountId?: string): Observable<OrganizationMaintainersResponse> {
105+
const options = accountId ? { params: { accountId } } : {};
106+
return this.http.get<OrganizationMaintainersResponse>('/api/analytics/organization-maintainers', options).pipe(
105107
catchError((error) => {
106108
console.error('Failed to fetch organization maintainers:', error);
107109
return of({
@@ -115,10 +117,12 @@ export class AnalyticsService {
115117

116118
/**
117119
* Get organization-level contributor statistics
120+
* @param accountId - Optional account ID to filter by specific organization
118121
* @returns Observable of organization contributors response
119122
*/
120-
public getOrganizationContributors(): Observable<OrganizationContributorsResponse> {
121-
return this.http.get<OrganizationContributorsResponse>('/api/analytics/organization-contributors').pipe(
123+
public getOrganizationContributors(accountId?: string): Observable<OrganizationContributorsResponse> {
124+
const options = accountId ? { params: { accountId } } : {};
125+
return this.http.get<OrganizationContributorsResponse>('/api/analytics/organization-contributors', options).pipe(
122126
catchError((error) => {
123127
console.error('Failed to fetch organization contributors:', error);
124128
return of({
@@ -133,10 +137,12 @@ export class AnalyticsService {
133137

134138
/**
135139
* Get organization membership tier details
140+
* @param accountId - Optional account ID to filter by specific organization
136141
* @returns Observable of membership tier response
137142
*/
138-
public getMembershipTier(): Observable<MembershipTierResponse> {
139-
return this.http.get<MembershipTierResponse>('/api/analytics/membership-tier').pipe(
143+
public getMembershipTier(accountId?: string): Observable<MembershipTierResponse> {
144+
const options = accountId ? { params: { accountId } } : {};
145+
return this.http.get<MembershipTierResponse>('/api/analytics/membership-tier', options).pipe(
140146
catchError((error) => {
141147
console.error('Failed to fetch membership tier:', error);
142148
return of({
@@ -153,10 +159,12 @@ export class AnalyticsService {
153159

154160
/**
155161
* Get organization-level event attendance statistics
162+
* @param accountId - Optional account ID to filter by specific organization
156163
* @returns Observable of organization event attendance response
157164
*/
158-
public getOrganizationEventAttendance(): Observable<OrganizationEventAttendanceResponse> {
159-
return this.http.get<OrganizationEventAttendanceResponse>('/api/analytics/organization-event-attendance').pipe(
165+
public getOrganizationEventAttendance(accountId?: string): Observable<OrganizationEventAttendanceResponse> {
166+
const options = accountId ? { params: { accountId } } : {};
167+
return this.http.get<OrganizationEventAttendanceResponse>('/api/analytics/organization-event-attendance', options).pipe(
160168
catchError((error) => {
161169
console.error('Failed to fetch organization event attendance:', error);
162170
return of({
@@ -172,10 +180,12 @@ export class AnalyticsService {
172180

173181
/**
174182
* Get organization-level technical committee participation statistics
183+
* @param accountId - Optional account ID to filter by specific organization
175184
* @returns Observable of organization technical committee response
176185
*/
177-
public getOrganizationTechnicalCommittee(): Observable<OrganizationTechnicalCommitteeResponse> {
178-
return this.http.get<OrganizationTechnicalCommitteeResponse>('/api/analytics/organization-technical-committee').pipe(
186+
public getOrganizationTechnicalCommittee(accountId?: string): Observable<OrganizationTechnicalCommitteeResponse> {
187+
const options = accountId ? { params: { accountId } } : {};
188+
return this.http.get<OrganizationTechnicalCommitteeResponse>('/api/analytics/organization-technical-committee', options).pipe(
179189
catchError((error) => {
180190
console.error('Failed to fetch organization technical committee:', error);
181191
return of({

0 commit comments

Comments
 (0)