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()) {
+
+ }
-
+
| 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