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 9bd9cc84..b2b38aae 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 @@ -6,9 +6,27 @@

My Projects

-
+
+ @if (loading()) { +
+
+ + Loading... +
+
+ }
- + Project 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 3e5e20c2..31fbc3a4 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 @@ -2,20 +2,16 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +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 type { ChartData, ChartOptions } from 'chart.js'; -import type { ProjectItem } from '@lfx-one/shared/interfaces'; - -/** - * Extended project item with pre-generated chart data - */ -interface ProjectItemWithCharts extends ProjectItem { - codeActivitiesChartData: ChartData<'line'>; - nonCodeActivitiesChartData: ChartData<'line'>; -} +import type { LazyLoadEvent } from 'primeng/api'; +import type { ProjectItemWithCharts } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-my-projects', @@ -25,10 +21,11 @@ interface ProjectItemWithCharts extends ProjectItem { styleUrl: './my-projects.component.scss', }) export class MyProjectsComponent { - /** - * Chart options for activity charts - */ - protected readonly chartOptions: ChartOptions<'line'> = { + private readonly analyticsService = inject(AnalyticsService); + private readonly paginationState$ = new BehaviorSubject({ page: 1, limit: 10 }); + protected readonly loading = signal(true); + + public readonly chartOptions: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, @@ -38,69 +35,35 @@ export class MyProjectsComponent { }, }; - /** - * Projects with pre-generated chart data - */ - protected readonly projects: ProjectItemWithCharts[]; + public readonly rows = signal(10); - public constructor() { - // Initialize projects with randomized chart data - const baseProjects: ProjectItem[] = [ - { - name: 'Kubernetes', - logo: 'https://avatars.githubusercontent.com/u/13455738?s=280&v=4', - role: 'Maintainer', - affiliations: ['CNCF', 'Google'], - codeActivities: this.generateRandomData(7, 25, 45), - nonCodeActivities: this.generateRandomData(7, 8, 16), - status: 'active', - }, - { - name: 'Linux Kernel', - logo: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg', - role: 'Contributor', - affiliations: ['Linux Foundation'], - codeActivities: this.generateRandomData(7, 12, 30), - nonCodeActivities: this.generateRandomData(7, 3, 9), - status: 'active', - }, - { - name: 'Node.js', - logo: 'https://nodejs.org/static/logos/nodejsHex.svg', - role: 'Reviewer', - affiliations: ['OpenJS Foundation'], - codeActivities: this.generateRandomData(7, 10, 20), - nonCodeActivities: this.generateRandomData(7, 4, 10), - status: 'archived', - }, - ]; + private readonly projectsResponse = toSignal( + this.paginationState$.pipe( + tap(() => this.loading.set(true)), + switchMap(({ page, limit }) => this.analyticsService.getMyProjects(page, limit).pipe(finalize(() => this.loading.set(false)))) + ), + { + initialValue: { data: [], totalProjects: 0 }, + } + ); - // Generate chart data for each project - this.projects = baseProjects.map((project) => ({ + public readonly projects = computed(() => { + const response = this.projectsResponse(); + return response.data.map((project) => ({ ...project, codeActivitiesChartData: this.createChartData(project.codeActivities, '#009AFF', 'rgba(0, 154, 255, 0.1)'), nonCodeActivitiesChartData: this.createChartData(project.nonCodeActivities, '#10b981', 'rgba(16, 185, 129, 0.1)'), })); - } + }); + + public readonly totalRecords = computed(() => this.projectsResponse().totalProjects); - /** - * Generates random data array - * @param length - Number of data points - * @param min - Minimum value - * @param max - Maximum value - * @returns Array of random numbers - */ - private generateRandomData(length: number, min: number, max: number): number[] { - return Array.from({ length }, () => Math.floor(Math.random() * (max - min + 1)) + min); + 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 }); } - /** - * Creates chart data configuration - * @param data - Array of values - * @param borderColor - Chart border color - * @param backgroundColor - Chart background color - * @returns Chart.js data configuration - */ 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/shared/services/analytics.service.ts b/apps/lfx-one/src/app/shared/services/analytics.service.ts index e1792cd8..2c375dab 100644 --- a/apps/lfx-one/src/app/shared/services/analytics.service.ts +++ b/apps/lfx-one/src/app/shared/services/analytics.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { ActiveWeeksStreakResponse, UserCodeCommitsResponse, UserPullRequestsResponse } from '@lfx-one/shared/interfaces'; +import { ActiveWeeksStreakResponse, UserCodeCommitsResponse, UserProjectsResponse, UserPullRequestsResponse } from '@lfx-one/shared/interfaces'; import { catchError, Observable, of } from 'rxjs'; /** @@ -66,4 +66,23 @@ 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( + catchError((error) => { + console.error('Failed to fetch my projects:', error); + return of({ + data: [], + totalProjects: 0, + }); + }) + ); + } } diff --git a/apps/lfx-one/src/server/controllers/analytics.controller.ts b/apps/lfx-one/src/server/controllers/analytics.controller.ts index 4ce1ed0c..ba94f37f 100644 --- a/apps/lfx-one/src/server/controllers/analytics.controller.ts +++ b/apps/lfx-one/src/server/controllers/analytics.controller.ts @@ -4,8 +4,11 @@ import { ActiveWeeksStreakResponse, ActiveWeeksStreakRow, + ProjectItem, UserCodeCommitsResponse, UserCodeCommitsRow, + UserProjectActivityRow, + UserProjectsResponse, UserPullRequestsResponse, UserPullRequestsRow, } from '@lfx-one/shared/interfaces'; @@ -114,7 +117,7 @@ export class AnalyticsController { ACTIVITY_DATE, DAILY_COUNT, SUM(DAILY_COUNT) OVER () as TOTAL_COUNT - FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM_LFX_ONE.USER_PULL_REQUESTS + FROM ANALYTICS.PLATINUM_LFX_ONE.USER_PULL_REQUESTS WHERE EMAIL = ? AND ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) ORDER BY ACTIVITY_DATE ASC @@ -177,7 +180,7 @@ export class AnalyticsController { ACTIVITY_DATE, DAILY_COUNT, SUM(DAILY_COUNT) OVER () as TOTAL_COUNT - FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM_LFX_ONE.USER_CODE_COMMITS + FROM ANALYTICS.PLATINUM_LFX_ONE.USER_CODE_COMMITS WHERE EMAIL = ? AND ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) ORDER BY ACTIVITY_DATE ASC @@ -215,6 +218,127 @@ 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) + */ + public async getMyProjects(req: Request, res: Response, next: NextFunction): Promise { + const startTime = Logger.start(req, 'get_my_projects'); + + try { + // Get user email from authenticated session (commented for future implementation) + // const userEmail = req.oidc?.user?.['email']; + // if (!userEmail) { + // throw new AuthenticationError('User email not found in authentication context', { + // operation: 'get_my_projects', + // }); + // } + + // 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)); + const offset = (page - 1) * limit; + + // First, get total count of unique projects + const countQuery = ` + SELECT COUNT(DISTINCT PROJECT_ID) as TOTAL_PROJECTS + FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY + WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) + `; + + // eslint-disable-next-line @typescript-eslint/naming-convention + const countResult = await this.getSnowflakeService().execute<{ TOTAL_PROJECTS: number }>(countQuery, []); + const totalProjects = countResult.rows[0]?.TOTAL_PROJECTS || 0; + + // If no projects found, return empty response + if (totalProjects === 0) { + Logger.success(req, 'get_my_projects', startTime, { + page, + limit, + total_projects: 0, + }); + + res.json({ + data: [], + totalProjects: 0, + }); + return; + } + + // Get paginated projects with all their activity data + // Use CTE to first get paginated project list, then join for activity data + const query = ` + WITH PaginatedProjects AS ( + SELECT DISTINCT PROJECT_ID, PROJECT_NAME, PROJECT_SLUG + FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY + WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) + ORDER BY PROJECT_NAME, PROJECT_ID + LIMIT ? OFFSET ? + ) + SELECT + p.PROJECT_ID, + p.PROJECT_NAME, + p.PROJECT_SLUG, + a.ACTIVITY_DATE, + a.DAILY_TOTAL_ACTIVITIES, + a.DAILY_CODE_ACTIVITIES, + a.DAILY_NON_CODE_ACTIVITIES + FROM PaginatedProjects p + JOIN ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY a + ON p.PROJECT_ID = a.PROJECT_ID + WHERE a.ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE()) + ORDER BY p.PROJECT_NAME, p.PROJECT_ID, a.ACTIVITY_DATE ASC + `; + + const result = await this.getSnowflakeService().execute(query, [limit, offset]); + + // 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 + projectsMap.set(row.PROJECT_ID, { + name: row.PROJECT_NAME, + logo: undefined, // Component will show default icon + role: 'Member', // Placeholder + affiliations: [], // Placeholder + 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); + } + + // Convert map to array + const projects = Array.from(projectsMap.values()); + + // Build response + const response: UserProjectsResponse = { + data: projects, + totalProjects, + }; + + Logger.success(req, 'get_my_projects', startTime, { + page, + limit, + returned_projects: projects.length, + total_projects: totalProjects, + }); + + res.json(response); + } catch (error) { + Logger.error(req, 'get_my_projects', startTime, error); + next(error); + } + } + /** * Lazy initialization of SnowflakeService * Ensures serverLogger is fully initialized before creating the service diff --git a/apps/lfx-one/src/server/routes/analytics.route.ts b/apps/lfx-one/src/server/routes/analytics.route.ts index 5ae29c11..5b4a1f0e 100644 --- a/apps/lfx-one/src/server/routes/analytics.route.ts +++ b/apps/lfx-one/src/server/routes/analytics.route.ts @@ -13,5 +13,6 @@ const analyticsController = new AnalyticsController(); router.get('/active-weeks-streak', (req, res, next) => analyticsController.getActiveWeeksStreak(req, res, next)); router.get('/pull-requests-merged', (req, res, next) => analyticsController.getPullRequestsMerged(req, res, next)); router.get('/code-commits', (req, res, next) => analyticsController.getCodeCommits(req, res, next)); +router.get('/my-projects', (req, res, next) => analyticsController.getMyProjects(req, res, next)); export default router; diff --git a/packages/shared/src/interfaces/analytics-data.interface.ts b/packages/shared/src/interfaces/analytics-data.interface.ts index f7263854..891a0233 100644 --- a/packages/shared/src/interfaces/analytics-data.interface.ts +++ b/packages/shared/src/interfaces/analytics-data.interface.ts @@ -1,6 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import type { ProjectItem } from './components.interface'; + /** * Active Weeks Streak row from Snowflake ACTIVE_WEEKS_STREAK table * Represents a single week's activity data for a user @@ -118,3 +120,59 @@ export interface UserCodeCommitsResponse { */ totalDays: number; } + +/** + * User Project Activity row from Snowflake PROJECT_CODE_ACTIVITY table + * Represents daily project activity data + */ +export interface UserProjectActivityRow { + /** + * Project unique identifier + */ + PROJECT_ID: string; + + /** + * Project display name + */ + PROJECT_NAME: string; + + /** + * Project URL slug + */ + PROJECT_SLUG: string; + + /** + * Date of the activity (YYYY-MM-DD format) + */ + ACTIVITY_DATE: string; + + /** + * Total activities (code + non-code) for this date + */ + DAILY_TOTAL_ACTIVITIES: number; + + /** + * Code-related activities for this date + */ + DAILY_CODE_ACTIVITIES: number; + + /** + * Non-code-related activities for this date + */ + DAILY_NON_CODE_ACTIVITIES: number; +} + +/** + * API response for User Projects query + */ +export interface UserProjectsResponse { + /** + * Array of projects with activity data + */ + data: ProjectItem[]; + + /** + * Total number of projects + */ + totalProjects: number; +} diff --git a/packages/shared/src/interfaces/components.interface.ts b/packages/shared/src/interfaces/components.interface.ts index b53adb32..7b5ce012 100644 --- a/packages/shared/src/interfaces/components.interface.ts +++ b/packages/shared/src/interfaces/components.interface.ts @@ -382,6 +382,17 @@ export interface ProjectItem { status: 'active' | 'archived'; } +/** + * Project item with pre-generated chart data for dashboard + * @description Extended project item with Chart.js line chart configurations + */ +export interface ProjectItemWithCharts extends ProjectItem { + /** Chart.js data configuration for code activities line chart */ + codeActivitiesChartData: ChartData<'line'>; + /** Chart.js data configuration for non-code activities line chart */ + nonCodeActivitiesChartData: ChartData<'line'>; +} + /** * Dashboard meeting card feature flags * @description Enabled features for a meeting displayed on dashboard