Skip to content

Commit c152c2b

Browse files
committed
feat(dashboard): implement server-side pagination for my projects
- Add pagination query parameters (page, limit) to analytics controller - Implement efficient two-query approach with CTE for Snowflake - Refactor component to use BehaviorSubject + toSignal pattern - Remove manual subscriptions for better memory management - Add loading indicator with signal-based state management - Enable PrimeNG lazy loading mode with pagination controls - Remove verbose comments for cleaner code LFXV2-682 Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent f89bab0 commit c152c2b

File tree

6 files changed

+255
-67
lines changed

6 files changed

+255
-67
lines changed

apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.html

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,27 @@
66
<h2 class="font-display font-semibold text-[16px]">My Projects</h2>
77
</div>
88

9-
<div class="rounded-lg border border-gray-200 overflow-hidden">
9+
<div class="rounded-lg border border-gray-200 overflow-hidden relative">
10+
@if (loading()) {
11+
<div class="absolute inset-0 bg-white/60 flex items-center justify-center z-10" data-testid="dashboard-my-projects-loading">
12+
<div class="flex flex-col items-center gap-2">
13+
<i class="fa-solid fa-spinner fa-spin text-[#009aff] text-2xl"></i>
14+
<span class="text-sm text-gray-600">Loading...</span>
15+
</div>
16+
</div>
17+
}
1018
<div class="overflow-x-auto">
11-
<lfx-table [value]="projects" data-testid="dashboard-my-projects-table">
19+
<lfx-table
20+
[value]="paginatedProjects()"
21+
[lazy]="true"
22+
[paginator]="true"
23+
[rows]="rows()"
24+
[totalRecords]="totalRecords()"
25+
[rowsPerPageOptions]="[5, 10, 20, 50]"
26+
[showCurrentPageReport]="true"
27+
[currentPageReportTemplate]="'Showing {first} to {last} of {totalRecords} projects'"
28+
(onLazyLoad)="onPageChange($event)"
29+
data-testid="dashboard-my-projects-table">
1230
<ng-template #header>
1331
<tr class="border-b border-border">
1432
<th class="text-left py-2 px-6 text-xs font-medium text-muted-foreground w-1/4">Project</th>

apps/lfx-one/src/app/modules/dashboards/components/my-projects/my-projects.component.ts

Lines changed: 30 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
// SPDX-License-Identifier: MIT
33

44
import { CommonModule } from '@angular/common';
5-
import { Component } from '@angular/core';
5+
import { Component, computed, inject, signal } from '@angular/core';
6+
import { toSignal } from '@angular/core/rxjs-interop';
67
import { ChartComponent } from '@components/chart/chart.component';
78
import { TableComponent } from '@components/table/table.component';
9+
import { AnalyticsService } from '@services/analytics.service';
10+
import { BehaviorSubject, finalize, switchMap, tap } from 'rxjs';
811

912
import type { ChartData, ChartOptions } from 'chart.js';
1013
import type { ProjectItem } from '@lfx-one/shared/interfaces';
1114

12-
/**
13-
* Extended project item with pre-generated chart data
14-
*/
1515
interface ProjectItemWithCharts extends ProjectItem {
1616
codeActivitiesChartData: ChartData<'line'>;
1717
nonCodeActivitiesChartData: ChartData<'line'>;
@@ -25,10 +25,11 @@ interface ProjectItemWithCharts extends ProjectItem {
2525
styleUrl: './my-projects.component.scss',
2626
})
2727
export class MyProjectsComponent {
28-
/**
29-
* Chart options for activity charts
30-
*/
31-
protected readonly chartOptions: ChartOptions<'line'> = {
28+
private readonly analyticsService = inject(AnalyticsService);
29+
private readonly paginationState$ = new BehaviorSubject({ page: 1, limit: 10 });
30+
protected readonly loading = signal(true);
31+
32+
public readonly chartOptions: ChartOptions<'line'> = {
3233
responsive: true,
3334
maintainAspectRatio: false,
3435
plugins: { legend: { display: false }, tooltip: { enabled: false } },
@@ -38,69 +39,36 @@ export class MyProjectsComponent {
3839
},
3940
};
4041

41-
/**
42-
* Projects with pre-generated chart data
43-
*/
44-
protected readonly projects: ProjectItemWithCharts[];
42+
public readonly rows = signal(10);
4543

46-
public constructor() {
47-
// Initialize projects with randomized chart data
48-
const baseProjects: ProjectItem[] = [
49-
{
50-
name: 'Kubernetes',
51-
logo: 'https://avatars.githubusercontent.com/u/13455738?s=280&v=4',
52-
role: 'Maintainer',
53-
affiliations: ['CNCF', 'Google'],
54-
codeActivities: this.generateRandomData(7, 25, 45),
55-
nonCodeActivities: this.generateRandomData(7, 8, 16),
56-
status: 'active',
57-
},
58-
{
59-
name: 'Linux Kernel',
60-
logo: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
61-
role: 'Contributor',
62-
affiliations: ['Linux Foundation'],
63-
codeActivities: this.generateRandomData(7, 12, 30),
64-
nonCodeActivities: this.generateRandomData(7, 3, 9),
65-
status: 'active',
66-
},
67-
{
68-
name: 'Node.js',
69-
logo: 'https://nodejs.org/static/logos/nodejsHex.svg',
70-
role: 'Reviewer',
71-
affiliations: ['OpenJS Foundation'],
72-
codeActivities: this.generateRandomData(7, 10, 20),
73-
nonCodeActivities: this.generateRandomData(7, 4, 10),
74-
status: 'archived',
75-
},
76-
];
44+
private readonly projectsResponse = toSignal(
45+
this.paginationState$.pipe(
46+
tap(() => this.loading.set(true)),
47+
switchMap(({ page, limit }) => this.analyticsService.getMyProjects(page, limit).pipe(finalize(() => this.loading.set(false))))
48+
),
49+
{
50+
initialValue: { data: [], totalProjects: 0 },
51+
}
52+
);
7753

78-
// Generate chart data for each project
79-
this.projects = baseProjects.map((project) => ({
54+
public readonly projects = computed<ProjectItemWithCharts[]>(() => {
55+
const response = this.projectsResponse();
56+
return response.data.map((project) => ({
8057
...project,
8158
codeActivitiesChartData: this.createChartData(project.codeActivities, '#009AFF', 'rgba(0, 154, 255, 0.1)'),
8259
nonCodeActivitiesChartData: this.createChartData(project.nonCodeActivities, '#10b981', 'rgba(16, 185, 129, 0.1)'),
8360
}));
84-
}
61+
});
62+
63+
public readonly totalRecords = computed(() => this.projectsResponse().totalProjects);
64+
public readonly paginatedProjects = computed<ProjectItemWithCharts[]>(() => this.projects());
8565

86-
/**
87-
* Generates random data array
88-
* @param length - Number of data points
89-
* @param min - Minimum value
90-
* @param max - Maximum value
91-
* @returns Array of random numbers
92-
*/
93-
private generateRandomData(length: number, min: number, max: number): number[] {
94-
return Array.from({ length }, () => Math.floor(Math.random() * (max - min + 1)) + min);
66+
public onPageChange(event: { first: number; rows: number }): void {
67+
const page = Math.floor(event.first / event.rows) + 1;
68+
this.rows.set(event.rows);
69+
this.paginationState$.next({ page, limit: event.rows });
9570
}
9671

97-
/**
98-
* Creates chart data configuration
99-
* @param data - Array of values
100-
* @param borderColor - Chart border color
101-
* @param backgroundColor - Chart background color
102-
* @returns Chart.js data configuration
103-
*/
10472
private createChartData(data: number[], borderColor: string, backgroundColor: string): ChartData<'line'> {
10573
return {
10674
labels: Array.from({ length: data.length }, () => ''),

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { HttpClient } from '@angular/common/http';
55
import { inject, Injectable } from '@angular/core';
6-
import { ActiveWeeksStreakResponse, UserCodeCommitsResponse, UserPullRequestsResponse } from '@lfx-one/shared/interfaces';
6+
import { ActiveWeeksStreakResponse, UserCodeCommitsResponse, UserProjectsResponse, UserPullRequestsResponse } from '@lfx-one/shared/interfaces';
77
import { catchError, Observable, of } from 'rxjs';
88

99
/**
@@ -66,4 +66,23 @@ export class AnalyticsService {
6666
})
6767
);
6868
}
69+
70+
/**
71+
* Get user's projects with activity data
72+
* @param page - Page number (1-based)
73+
* @param limit - Number of projects per page
74+
* @returns Observable of user projects response
75+
*/
76+
public getMyProjects(page: number = 1, limit: number = 10): Observable<UserProjectsResponse> {
77+
const params = { page: page.toString(), limit: limit.toString() };
78+
return this.http.get<UserProjectsResponse>('/api/analytics/my-projects', { params }).pipe(
79+
catchError((error) => {
80+
console.error('Failed to fetch my projects:', error);
81+
return of({
82+
data: [],
83+
totalProjects: 0,
84+
});
85+
})
86+
);
87+
}
6988
}

apps/lfx-one/src/server/controllers/analytics.controller.ts

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
import {
55
ActiveWeeksStreakResponse,
66
ActiveWeeksStreakRow,
7+
ProjectItem,
78
UserCodeCommitsResponse,
89
UserCodeCommitsRow,
10+
UserProjectActivityRow,
11+
UserProjectsResponse,
912
UserPullRequestsResponse,
1013
UserPullRequestsRow,
1114
} from '@lfx-one/shared/interfaces';
@@ -114,7 +117,7 @@ export class AnalyticsController {
114117
ACTIVITY_DATE,
115118
DAILY_COUNT,
116119
SUM(DAILY_COUNT) OVER () as TOTAL_COUNT
117-
FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM_LFX_ONE.USER_PULL_REQUESTS
120+
FROM ANALYTICS.PLATINUM_LFX_ONE.USER_PULL_REQUESTS
118121
WHERE EMAIL = ?
119122
AND ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE())
120123
ORDER BY ACTIVITY_DATE ASC
@@ -177,7 +180,7 @@ export class AnalyticsController {
177180
ACTIVITY_DATE,
178181
DAILY_COUNT,
179182
SUM(DAILY_COUNT) OVER () as TOTAL_COUNT
180-
FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM_LFX_ONE.USER_CODE_COMMITS
183+
FROM ANALYTICS.PLATINUM_LFX_ONE.USER_CODE_COMMITS
181184
WHERE EMAIL = ?
182185
AND ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE())
183186
ORDER BY ACTIVITY_DATE ASC
@@ -215,6 +218,127 @@ export class AnalyticsController {
215218
}
216219
}
217220

221+
/**
222+
* GET /api/analytics/my-projects
223+
* Get user's projects with activity data for the last 30 days
224+
* Supports pagination via query parameters: page (default 1) and limit (default 10)
225+
*/
226+
public async getMyProjects(req: Request, res: Response, next: NextFunction): Promise<void> {
227+
const startTime = Logger.start(req, 'get_my_projects');
228+
229+
try {
230+
// Get user email from authenticated session (commented for future implementation)
231+
// const userEmail = req.oidc?.user?.['email'];
232+
// if (!userEmail) {
233+
// throw new AuthenticationError('User email not found in authentication context', {
234+
// operation: 'get_my_projects',
235+
// });
236+
// }
237+
238+
// Parse pagination parameters
239+
const page = Math.max(1, parseInt(req.query['page'] as string, 10) || 1);
240+
const limit = Math.max(1, Math.min(100, parseInt(req.query['limit'] as string, 10) || 10));
241+
const offset = (page - 1) * limit;
242+
243+
// First, get total count of unique projects
244+
const countQuery = `
245+
SELECT COUNT(DISTINCT PROJECT_ID) as TOTAL_PROJECTS
246+
FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY
247+
WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE())
248+
`;
249+
250+
// eslint-disable-next-line @typescript-eslint/naming-convention
251+
const countResult = await this.getSnowflakeService().execute<{ TOTAL_PROJECTS: number }>(countQuery, []);
252+
const totalProjects = countResult.rows[0]?.TOTAL_PROJECTS || 0;
253+
254+
// If no projects found, return empty response
255+
if (totalProjects === 0) {
256+
Logger.success(req, 'get_my_projects', startTime, {
257+
page,
258+
limit,
259+
total_projects: 0,
260+
});
261+
262+
res.json({
263+
data: [],
264+
totalProjects: 0,
265+
});
266+
return;
267+
}
268+
269+
// Get paginated projects with all their activity data
270+
// Use CTE to first get paginated project list, then join for activity data
271+
const query = `
272+
WITH PaginatedProjects AS (
273+
SELECT DISTINCT PROJECT_ID, PROJECT_NAME, PROJECT_SLUG
274+
FROM ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY
275+
WHERE ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE())
276+
ORDER BY PROJECT_NAME
277+
LIMIT ? OFFSET ?
278+
)
279+
SELECT
280+
p.PROJECT_ID,
281+
p.PROJECT_NAME,
282+
p.PROJECT_SLUG,
283+
a.ACTIVITY_DATE,
284+
a.DAILY_TOTAL_ACTIVITIES,
285+
a.DAILY_CODE_ACTIVITIES,
286+
a.DAILY_NON_CODE_ACTIVITIES
287+
FROM PaginatedProjects p
288+
JOIN ANALYTICS_DEV.DEV_JEVANS_PLATINUM.PROJECT_CODE_ACTIVITY a
289+
ON p.PROJECT_ID = a.PROJECT_ID
290+
WHERE a.ACTIVITY_DATE >= DATEADD(DAY, -30, CURRENT_DATE())
291+
ORDER BY p.PROJECT_NAME, a.ACTIVITY_DATE ASC
292+
`;
293+
294+
const result = await this.getSnowflakeService().execute<UserProjectActivityRow>(query, [limit, offset]);
295+
296+
// Group rows by PROJECT_ID and transform into ProjectItem[]
297+
const projectsMap = new Map<string, ProjectItem>();
298+
299+
for (const row of result.rows) {
300+
if (!projectsMap.has(row.PROJECT_ID)) {
301+
// Initialize new project with placeholder values
302+
projectsMap.set(row.PROJECT_ID, {
303+
name: row.PROJECT_NAME,
304+
logo: undefined, // Component will show default icon
305+
role: 'Member', // Placeholder
306+
affiliations: [], // Placeholder
307+
codeActivities: [],
308+
nonCodeActivities: [],
309+
status: 'active', // Placeholder
310+
});
311+
}
312+
313+
// Add daily activity values to arrays
314+
const project = projectsMap.get(row.PROJECT_ID)!;
315+
project.codeActivities.push(row.DAILY_CODE_ACTIVITIES);
316+
project.nonCodeActivities.push(row.DAILY_NON_CODE_ACTIVITIES);
317+
}
318+
319+
// Convert map to array
320+
const projects = Array.from(projectsMap.values());
321+
322+
// Build response
323+
const response: UserProjectsResponse = {
324+
data: projects,
325+
totalProjects,
326+
};
327+
328+
Logger.success(req, 'get_my_projects', startTime, {
329+
page,
330+
limit,
331+
returned_projects: projects.length,
332+
total_projects: totalProjects,
333+
});
334+
335+
res.json(response);
336+
} catch (error) {
337+
Logger.error(req, 'get_my_projects', startTime, error);
338+
next(error);
339+
}
340+
}
341+
218342
/**
219343
* Lazy initialization of SnowflakeService
220344
* Ensures serverLogger is fully initialized before creating the service

apps/lfx-one/src/server/routes/analytics.route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ const analyticsController = new AnalyticsController();
1313
router.get('/active-weeks-streak', (req, res, next) => analyticsController.getActiveWeeksStreak(req, res, next));
1414
router.get('/pull-requests-merged', (req, res, next) => analyticsController.getPullRequestsMerged(req, res, next));
1515
router.get('/code-commits', (req, res, next) => analyticsController.getCodeCommits(req, res, next));
16+
router.get('/my-projects', (req, res, next) => analyticsController.getMyProjects(req, res, next));
1617

1718
export default router;

0 commit comments

Comments
 (0)