From bab2101da6b09421771e36509a1280612201bb7a Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 4 Dec 2025 10:48:21 -0800 Subject: [PATCH] feat(dashboards): refactor my-projects to use user contribution data - Remove pagination from my-projects component, add empty state - Update analytics service and controller to use LF username - Refactor user service to query USER_PROJECT_CONTRIBUTIONS_DAILY table - Update interfaces with new fields (PROJECT_LOGO, IS_MAINTAINER, AFFILIATION) - Fix board member dashboard available accounts binding (LFXV2-874) - Update RSVP disabled message for legacy meetings (LFXV2-875) - Hide data copilot sparkle icon temporarily (LFXV2-876) LFXV2-873 LFXV2-874 LFXV2-875 LFXV2-876 Signed-off-by: Asitha de Silva --- .../board-member-dashboard.component.html | 2 +- .../board-member-dashboard.component.ts | 3 +- .../my-projects/my-projects.component.html | 23 ++-- .../my-projects/my-projects.component.ts | 16 +-- .../meeting-card/meeting-card.component.html | 4 +- .../data-copilot/data-copilot.component.html | 2 +- .../app/shared/services/analytics.service.ts | 7 +- .../controllers/analytics.controller.ts | 19 ++-- .../src/server/services/user.service.ts | 102 +++++++++--------- .../interfaces/analytics-data.interface.ts | 20 +++- .../src/interfaces/components.interface.ts | 16 +-- 11 files changed, 109 insertions(+), 105 deletions(-) diff --git a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html index 11dec2e5..bba08d99 100644 --- a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html @@ -16,7 +16,7 @@

{{ selectedFoundation()?.name }} Overview

(this.accountContextService.selectedAccount().accountId), }); - public readonly availableAccounts: Signal = computed(() => this.accountContextService.availableAccounts); + public readonly availableAccounts = ACCOUNTS; public readonly selectedFoundation = computed(() => this.projectContextService.selectedFoundation()); public readonly selectedProject = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); public readonly refresh$: BehaviorSubject = new BehaviorSubject(undefined); diff --git a/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.html index b8c72ebd..e579824c 100644 --- a/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.html @@ -16,17 +16,7 @@

My Projects

}
- + Project @@ -87,6 +77,17 @@

My Projects

+ + + + +
+ + No projects found +
+ + +
diff --git a/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.ts index 8a563f7c..23404b91 100644 --- a/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.ts @@ -7,10 +7,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ChartComponent } from '@components/chart/chart.component'; import { TableComponent } from '@components/table/table.component'; import { AnalyticsService } from '@services/analytics.service'; -import { BehaviorSubject, finalize, switchMap, tap } from 'rxjs'; +import { finalize, tap } from 'rxjs'; import type { ChartData, ChartOptions } from 'chart.js'; -import type { LazyLoadEvent } from 'primeng/api'; import type { ProjectItemWithCharts } from '@lfx-one/shared/interfaces'; import { hexToRgba, lfxColors } from '@lfx-one/shared'; @@ -23,7 +22,6 @@ import { hexToRgba, lfxColors } from '@lfx-one/shared'; }) export class MyProjectsComponent { private readonly analyticsService = inject(AnalyticsService); - private readonly paginationState$ = new BehaviorSubject({ page: 1, limit: 10 }); protected readonly loading = signal(true); public readonly chartOptions: ChartOptions<'line'> = { @@ -36,12 +34,10 @@ export class MyProjectsComponent { }, }; - public readonly rows = signal(10); - private readonly projectsResponse = toSignal( - this.paginationState$.pipe( + this.analyticsService.getMyProjects().pipe( tap(() => this.loading.set(true)), - switchMap(({ page, limit }) => this.analyticsService.getMyProjects(page, limit).pipe(finalize(() => this.loading.set(false)))) + finalize(() => this.loading.set(false)) ), { initialValue: { data: [], totalProjects: 0 }, @@ -59,12 +55,6 @@ export class MyProjectsComponent { public readonly totalRecords = computed(() => this.projectsResponse().totalProjects); - public onPageChange(event: LazyLoadEvent): void { - const page = Math.floor((event.first ?? 0) / (event.rows ?? 10)) + 1; - this.rows.set(event.rows ?? 10); - this.paginationState$.next({ page, limit: event.rows ?? 10 }); - } - private createChartData(data: number[], borderColor: string, backgroundColor: string): ChartData<'line'> { return { labels: Array.from({ length: data.length }, () => ''), diff --git a/apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html b/apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html index c980e557..e331a02c 100644 --- a/apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html +++ b/apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html @@ -268,7 +268,7 @@

+ disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you"> } @else if (!pastMeeting()) { @@ -277,7 +277,7 @@

+ disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you"> } @else if (canRegisterForMeeting()) {
diff --git a/apps/lfx-one/src/app/shared/components/data-copilot/data-copilot.component.html b/apps/lfx-one/src/app/shared/components/data-copilot/data-copilot.component.html index bacbab57..d4aa0894 100644 --- a/apps/lfx-one/src/app/shared/components/data-copilot/data-copilot.component.html +++ b/apps/lfx-one/src/app/shared/components/data-copilot/data-copilot.component.html @@ -11,7 +11,7 @@ styleClass="px-5 py-2.5 bg-white hover:bg-blue-50 text-blue-500 rounded-full font-medium flex items-center gap-2 border border-blue-500 transition-all"> - + } 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 d7a604a2..d8d3efeb 100644 --- a/apps/lfx-one/src/app/shared/services/analytics.service.ts +++ b/apps/lfx-one/src/app/shared/services/analytics.service.ts @@ -94,13 +94,10 @@ export class AnalyticsService { /** * Get user's projects with activity data - * @param page - Page number (1-based) - * @param limit - Number of projects per page * @returns Observable of user projects response */ - public getMyProjects(page: number = 1, limit: number = 10): Observable { - const params = { page: page.toString(), limit: limit.toString() }; - return this.http.get('/api/analytics/my-projects', { params }).pipe( + public getMyProjects(): Observable { + return this.http.get('/api/analytics/my-projects').pipe( catchError((error) => { console.error('Failed to fetch my projects:', error); return of({ diff --git a/apps/lfx-one/src/server/controllers/analytics.controller.ts b/apps/lfx-one/src/server/controllers/analytics.controller.ts index 1c164b47..fbc2e007 100644 --- a/apps/lfx-one/src/server/controllers/analytics.controller.ts +++ b/apps/lfx-one/src/server/controllers/analytics.controller.ts @@ -8,6 +8,7 @@ import { Logger } from '../helpers/logger'; import { OrganizationService } from '../services/organization.service'; import { ProjectService } from '../services/project.service'; import { UserService } from '../services/user.service'; +import { getUsernameFromAuth } from '../utils/auth-helper'; /** * Controller for handling analytics HTTP requests @@ -116,22 +117,24 @@ export class AnalyticsController { /** * GET /api/analytics/my-projects - * Get user's projects with activity data for the last 30 days - * Supports pagination via query parameters: page (default 1) and limit (default 10) + * Get user's projects with activity data */ public async getMyProjects(req: Request, res: Response, next: NextFunction): Promise { const startTime = Logger.start(req, 'get_my_projects'); try { - // Parse pagination parameters - const page = Math.max(1, parseInt(req.query['page'] as string, 10) || 1); - const limit = Math.max(1, Math.min(100, parseInt(req.query['limit'] as string, 10) || 10)); + // Get LF username from OIDC context + const lfUsername = await getUsernameFromAuth(req); - const response = await this.userService.getMyProjects(page, limit); + if (!lfUsername) { + throw new AuthenticationError('User username not found in authentication context', { + operation: 'get_my_projects', + }); + } + + const response = await this.userService.getMyProjects(lfUsername); Logger.success(req, 'get_my_projects', startTime, { - page, - limit, returned_projects: response.data.length, total_projects: response.totalProjects, }); diff --git a/apps/lfx-one/src/server/services/user.service.ts b/apps/lfx-one/src/server/services/user.service.ts index 82a70674..e6ab362c 100644 --- a/apps/lfx-one/src/server/services/user.service.ts +++ b/apps/lfx-one/src/server/services/user.service.ts @@ -11,7 +11,6 @@ import { MeetingRegistrant, PendingActionItem, PersonaType, - ProjectCountRow, ProjectItem, QueryServiceResponse, UserCodeCommitsResponse, @@ -19,7 +18,7 @@ import { UserMetadata, UserMetadataUpdateRequest, UserMetadataUpdateResponse, - UserProjectActivityRow, + UserProjectContributionRow, UserProjectsResponse, UserPullRequestsResponse, UserPullRequestsRow, @@ -372,80 +371,83 @@ export class UserService { } /** - * Get user's projects with activity data for the last 30 days - * @param page - Page number (1-indexed) - * @param limit - Number of projects per page - * @returns Paginated projects with activity data + * Get user's projects with activity data + * Queries USER_PROJECT_CONTRIBUTIONS_DAILY table filtered by LF username + * @param lfUsername - Linux Foundation username from OIDC + * @returns All projects with activity data for the user */ - public async getMyProjects(page: number, limit: number): Promise { - const offset = (page - 1) * limit; - - // First, get total count of unique projects - const countQuery = ` - SELECT COUNT(DISTINCT PROJECT_ID) as TOTAL_PROJECTS - FROM ANALYTICS.PLATINUM_LFX_ONE.PROJECT_CODE_ACTIVITY - WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) - `; - - const countResult = await this.snowflakeService.execute(countQuery, []); - const totalProjects = countResult.rows[0]?.TOTAL_PROJECTS || 0; - - // If no projects found, return empty response - if (totalProjects === 0) { - return { - data: [], - totalProjects: 0, - }; - } - - // Get paginated projects with all their activity data - // Use CTE to first get paginated project list, then join for activity data + public async getMyProjects(lfUsername: string): Promise { + // Get all projects with their activity data + // Aggregates affiliations per project and sums activities by date const query = ` - WITH PaginatedProjects AS ( - SELECT DISTINCT PROJECT_ID, PROJECT_NAME, PROJECT_SLUG - FROM ANALYTICS.PLATINUM_LFX_ONE.PROJECT_CODE_ACTIVITY - WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) + WITH UserProjects AS ( + SELECT PROJECT_ID, PROJECT_NAME, PROJECT_SLUG, PROJECT_LOGO, + MAX(IS_MAINTAINER) AS IS_MAINTAINER + FROM ANALYTICS.PLATINUM_LFX_ONE.USER_PROJECT_CONTRIBUTIONS_DAILY + WHERE LF_USERNAME = ? + GROUP BY PROJECT_ID, PROJECT_NAME, PROJECT_SLUG, PROJECT_LOGO ORDER BY PROJECT_NAME, PROJECT_ID - LIMIT ? OFFSET ? + ), + ProjectAffiliations AS ( + SELECT PROJECT_ID, LISTAGG(DISTINCT AFFILIATION, ', ') WITHIN GROUP (ORDER BY AFFILIATION) AS AFFILIATIONS + FROM ANALYTICS.PLATINUM_LFX_ONE.USER_PROJECT_CONTRIBUTIONS_DAILY + WHERE LF_USERNAME = ? + AND AFFILIATION IS NOT NULL + AND AFFILIATION != '' + GROUP BY PROJECT_ID + ), + DailyActivities AS ( + SELECT PROJECT_ID, ACTIVITY_DATE, + SUM(DAILY_CODE_ACTIVITIES) AS DAILY_CODE_ACTIVITIES, + SUM(DAILY_NON_CODE_ACTIVITIES) AS DAILY_NON_CODE_ACTIVITIES + FROM ANALYTICS.PLATINUM_LFX_ONE.USER_PROJECT_CONTRIBUTIONS_DAILY + WHERE LF_USERNAME = ? + GROUP BY PROJECT_ID, ACTIVITY_DATE ) SELECT p.PROJECT_ID, p.PROJECT_NAME, p.PROJECT_SLUG, + p.PROJECT_LOGO, + p.IS_MAINTAINER, + COALESCE(pa.AFFILIATIONS, '') AS AFFILIATION, a.ACTIVITY_DATE, - a.DAILY_TOTAL_ACTIVITIES, a.DAILY_CODE_ACTIVITIES, a.DAILY_NON_CODE_ACTIVITIES - FROM PaginatedProjects p - JOIN ANALYTICS.PLATINUM_LFX_ONE.PROJECT_CODE_ACTIVITY a - ON p.PROJECT_ID = a.PROJECT_ID - WHERE a.ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) + FROM UserProjects p + LEFT JOIN ProjectAffiliations pa ON p.PROJECT_ID = pa.PROJECT_ID + LEFT JOIN DailyActivities a ON p.PROJECT_ID = a.PROJECT_ID ORDER BY p.PROJECT_NAME, p.PROJECT_ID, a.ACTIVITY_DATE ASC `; - const result = await this.snowflakeService.execute(query, [limit, offset]); + const result = await this.snowflakeService.execute(query, [lfUsername, lfUsername, lfUsername]); // Group rows by PROJECT_ID and transform into ProjectItem[] const projectsMap = new Map(); for (const row of result.rows) { if (!projectsMap.has(row.PROJECT_ID)) { - // Initialize new project with placeholder values + // Parse affiliations from comma-separated string + const affiliations = row.AFFILIATION ? row.AFFILIATION.split(', ').filter((a) => a.trim()) : []; + + // Initialize new project projectsMap.set(row.PROJECT_ID, { name: row.PROJECT_NAME, - logo: undefined, // Component will show default icon - role: 'Member', // Placeholder - affiliations: [], // Placeholder + slug: row.PROJECT_SLUG, + logo: row.PROJECT_LOGO || undefined, + role: row.IS_MAINTAINER ? 'Maintainer' : 'Contributor', + affiliations, codeActivities: [], nonCodeActivities: [], - status: 'active', // Placeholder }); } - // Add daily activity values to arrays - const project = projectsMap.get(row.PROJECT_ID)!; - project.codeActivities.push(row.DAILY_CODE_ACTIVITIES); - project.nonCodeActivities.push(row.DAILY_NON_CODE_ACTIVITIES); + // Add daily activity values to arrays (if there's activity data) + if (row.ACTIVITY_DATE) { + const project = projectsMap.get(row.PROJECT_ID)!; + project.codeActivities.push(row.DAILY_CODE_ACTIVITIES || 0); + project.nonCodeActivities.push(row.DAILY_NON_CODE_ACTIVITIES || 0); + } } // Convert map to array @@ -453,7 +455,7 @@ export class UserService { return { data: projects, - totalProjects, + totalProjects: projects.length, }; } diff --git a/packages/shared/src/interfaces/analytics-data.interface.ts b/packages/shared/src/interfaces/analytics-data.interface.ts index d0baf189..011b9b74 100644 --- a/packages/shared/src/interfaces/analytics-data.interface.ts +++ b/packages/shared/src/interfaces/analytics-data.interface.ts @@ -122,10 +122,10 @@ export interface UserCodeCommitsResponse { } /** - * User Project Activity row from Snowflake PROJECT_CODE_ACTIVITY table - * Represents daily project activity data + * User Project Contribution row from Snowflake USER_PROJECT_CONTRIBUTIONS_DAILY table + * Represents daily contribution activity for a user's projects */ -export interface UserProjectActivityRow { +export interface UserProjectContributionRow { /** * Project unique identifier */ @@ -141,15 +141,25 @@ export interface UserProjectActivityRow { */ PROJECT_SLUG: string; + /** + * Project logo URL + */ + PROJECT_LOGO: string | null; + /** * Date of the activity (YYYY-MM-DD format) */ ACTIVITY_DATE: string; /** - * Total activities (code + non-code) for this date + * Whether the user is a maintainer of this project + */ + IS_MAINTAINER: boolean; + + /** + * User's affiliation/organization name */ - DAILY_TOTAL_ACTIVITIES: number; + AFFILIATION: string | null; /** * Code-related activities for this date diff --git a/packages/shared/src/interfaces/components.interface.ts b/packages/shared/src/interfaces/components.interface.ts index 7ef03e4e..b13d037b 100644 --- a/packages/shared/src/interfaces/components.interface.ts +++ b/packages/shared/src/interfaces/components.interface.ts @@ -415,23 +415,23 @@ export interface MeetingItem { /** * Project item for project list - * @description Structure for project information + * @description Structure for project information from USER_PROJECT_CONTRIBUTIONS_DAILY */ export interface ProjectItem { /** Project name */ name: string; + /** Project URL slug */ + slug: string; /** Project logo URL */ logo?: string; - /** User's role in project */ - role: string; - /** User's affiliations */ + /** User's role in project (Maintainer or Contributor) */ + role: 'Maintainer' | 'Contributor'; + /** User's affiliations (comma-separated if multiple) */ affiliations: string[]; - /** Code activity data for chart */ + /** Code activity data for chart (daily values) */ codeActivities: number[]; - /** Non-code activity data for chart */ + /** Non-code activity data for chart (daily values) */ nonCodeActivities: number[]; - /** Project status */ - status: 'active' | 'archived'; } /**