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 @@ -16,7 +16,7 @@ <h1>{{ selectedFoundation()?.name }} Overview</h1>
<lfx-select
[form]="form"
control="selectedAccountId"
[options]="availableAccounts()"
[options]="availableAccounts"
optionLabel="accountName"
optionValue="accountId"
[filter]="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Component, computed, inject, Signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { SelectComponent } from '@components/select/select.component';
import { ACCOUNTS } from '@lfx-one/shared';
import { Account, PendingActionItem } from '@lfx-one/shared/interfaces';
import { AccountContextService } from '@services/account-context.service';
import { FeatureFlagService } from '@services/feature-flag.service';
Expand Down Expand Up @@ -35,7 +36,7 @@ export class BoardMemberDashboardComponent {
selectedAccountId: new FormControl<string>(this.accountContextService.selectedAccount().accountId),
});

public readonly availableAccounts: Signal<Account[]> = 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<void> = new BehaviorSubject<void>(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,7 @@ <h2 class="font-display font-semibold text-[16px]">My Projects</h2>
</div>
}
<div class="overflow-x-auto">
<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">
<lfx-table [value]="projects()" 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-gray-500 w-1/4">Project</th>
Expand Down Expand Up @@ -87,6 +77,17 @@ <h2 class="font-display font-semibold text-[16px]">My Projects</h2>
</td>
</tr>
</ng-template>

<ng-template #emptymessage>
<tr>
<td colspan="4" class="text-center py-8" data-testid="dashboard-my-projects-empty">
<div class="flex flex-col items-center gap-2">
<i class="fa-light fa-folder-open text-gray-400 text-3xl"></i>
<span class="text-sm text-gray-500">No projects found</span>
</div>
</td>
</tr>
</ng-template>
</lfx-table>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'> = {
Expand All @@ -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 },
Expand All @@ -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 }, () => ''),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
[showAddButton]="!pastMeeting()"
[additionalRegistrantsCount]="additionalRegistrantsCount()"
[disabled]="isLegacyMeeting()"
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you">
</lfx-meeting-rsvp-details>
} @else if (!pastMeeting()) {
<!-- Show RSVP Selection for authenticated invited non-organizers (upcoming meetings only) -->
Expand All @@ -277,7 +277,7 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
[meeting]="meeting()"
[occurrenceId]="occurrence()?.occurrence_id"
[disabled]="isLegacyMeeting()"
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you">
</lfx-rsvp-button-group>
} @else if (canRegisterForMeeting()) {
<div class="h-full flex items-center justify-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<span class="fa-stack" style="font-size: 0.6em; width: 2em; height: 2em; line-height: 2em">
<i class="fa-light fa-comment fa-stack-2x"></i>
<i class="fa-solid fa-sparkle fa-stack-1x text-yellow-400" style="top: -1em; left: 1.25em"></i>
<!-- <i class="fa-solid fa-sparkle fa-stack-1x text-yellow-400" style="top: -1em; left: 1.25em"></i> -->
</span>
</lfx-button>
}
Expand Down
7 changes: 2 additions & 5 deletions apps/lfx-one/src/app/shared/services/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserProjectsResponse> {
const params = { page: page.toString(), limit: limit.toString() };
return this.http.get<UserProjectsResponse>('/api/analytics/my-projects', { params }).pipe(
public getMyProjects(): Observable<UserProjectsResponse> {
return this.http.get<UserProjectsResponse>('/api/analytics/my-projects').pipe(
catchError((error) => {
console.error('Failed to fetch my projects:', error);
return of({
Expand Down
19 changes: 11 additions & 8 deletions apps/lfx-one/src/server/controllers/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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,
});
Expand Down
102 changes: 52 additions & 50 deletions apps/lfx-one/src/server/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ import {
MeetingRegistrant,
PendingActionItem,
PersonaType,
ProjectCountRow,
ProjectItem,
QueryServiceResponse,
UserCodeCommitsResponse,
UserCodeCommitsRow,
UserMetadata,
UserMetadataUpdateRequest,
UserMetadataUpdateResponse,
UserProjectActivityRow,
UserProjectContributionRow,
UserProjectsResponse,
UserPullRequestsResponse,
UserPullRequestsRow,
Expand Down Expand Up @@ -372,88 +371,91 @@ 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<UserProjectsResponse> {
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<ProjectCountRow>(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<UserProjectsResponse> {
// 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<UserProjectActivityRow>(query, [limit, offset]);
const result = await this.snowflakeService.execute<UserProjectContributionRow>(query, [lfUsername, lfUsername, lfUsername]);

// 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
// 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
const projects = Array.from(projectsMap.values());

return {
data: projects,
totalProjects,
totalProjects: projects.length,
};
}

Expand Down
20 changes: 15 additions & 5 deletions packages/shared/src/interfaces/analytics-data.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand Down
Loading