Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,27 @@
<h2 class="font-display font-semibold text-[16px]">My Projects</h2>
</div>

<div class="rounded-lg border border-gray-200 overflow-hidden">
<div class="rounded-lg border border-gray-200 overflow-hidden relative">
@if (loading()) {
<div class="absolute inset-0 bg-white/60 flex items-center justify-center z-10" data-testid="dashboard-my-projects-loading">
<div class="flex flex-col items-center gap-2">
<i class="fa-solid fa-spinner fa-spin text-[#009aff] text-2xl"></i>
<span class="text-sm text-gray-600">Loading...</span>
</div>
</div>
}
<div class="overflow-x-auto">
<lfx-table [value]="projects" data-testid="dashboard-my-projects-table">
<lfx-table
[value]="projects()"
[lazy]="true"
[paginator]="true"
[rows]="rows()"
[totalRecords]="totalRecords()"
[rowsPerPageOptions]="[5, 10, 20, 50]"
[showCurrentPageReport]="true"
[currentPageReportTemplate]="'Showing {first} to {last} of {totalRecords} projects'"
(onLazyLoad)="onPageChange($event)"
data-testid="dashboard-my-projects-table">
<ng-template #header>
<tr class="border-b border-border">
<th class="text-left py-2 px-6 text-xs font-medium text-muted-foreground w-1/4">Project</th>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 } },
Expand All @@ -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<ProjectItemWithCharts[]>(() => {
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 }, () => ''),
Expand Down
21 changes: 20 additions & 1 deletion apps/lfx-one/src/app/shared/services/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<UserProjectsResponse> {
const params = { page: page.toString(), limit: limit.toString() };
return this.http.get<UserProjectsResponse>('/api/analytics/my-projects', { params }).pipe(
catchError((error) => {
console.error('Failed to fetch my projects:', error);
return of({
data: [],
totalProjects: 0,
});
})
);
}
}
128 changes: 126 additions & 2 deletions apps/lfx-one/src/server/controllers/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import {
ActiveWeeksStreakResponse,
ActiveWeeksStreakRow,
ProjectItem,
UserCodeCommitsResponse,
UserCodeCommitsRow,
UserProjectActivityRow,
UserProjectsResponse,
UserPullRequestsResponse,
UserPullRequestsRow,
} from '@lfx-one/shared/interfaces';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<UserProjectActivityRow>(query, [limit, offset]);

// Group rows by PROJECT_ID and transform into ProjectItem[]
const projectsMap = new Map<string, ProjectItem>();

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
Expand Down
1 change: 1 addition & 0 deletions apps/lfx-one/src/server/routes/analytics.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading