diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml
index f14b6b2e..cd99bd6c 100644
--- a/.github/workflows/quality-check.yml
+++ b/.github/workflows/quality-check.yml
@@ -3,10 +3,10 @@
name: Quality Checks
+# DISABLED: Moving away from old UI - quality checks no longer needed
+# Changed to manual trigger only - will not run automatically on PRs
on:
- pull_request:
- branches: [main]
- types: [opened, synchronize, reopened]
+ workflow_dispatch:
jobs:
quality-checks:
@@ -23,8 +23,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: '22'
- cache: 'yarn'
+ node-version: "22"
+ cache: "yarn"
- name: Install dependencies
run: yarn install --immutable
@@ -46,9 +46,9 @@ jobs:
needs: quality-checks
uses: ./.github/workflows/e2e-tests.yml
with:
- node-version: '22'
- test-command: 'e2e'
- browser: 'chromium' # Use only Chromium for PR testing for faster feedback
+ node-version: "22"
+ test-command: "e2e"
+ browser: "chromium" # Use only Chromium for PR testing for faster feedback
skip-build: false
secrets:
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts
index f7c40baa..924b761a 100644
--- a/apps/lfx-one/src/app/app.routes.ts
+++ b/apps/lfx-one/src/app/app.routes.ts
@@ -8,8 +8,24 @@ import { authGuard } from './shared/guards/auth.guard';
export const routes: Routes = [
{
path: '',
- loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
+ loadComponent: () => import('./layouts/main-layout/main-layout.component').then((m) => m.MainLayoutComponent),
+ children: [
+ {
+ path: '',
+ loadComponent: () => import('./modules/pages/dashboard/dashboard.component').then((m) => m.DashboardComponent),
+ },
+ {
+ path: 'projects',
+ loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
+ },
+ ],
+ },
+ // Old UI route - shows when "Old UI" persona is selected
+ {
+ path: 'old-ui',
+ canActivate: [authGuard],
+ loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'meetings',
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html
new file mode 100644
index 00000000..d2c3c23e
--- /dev/null
+++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+ @if (showMobileSidebar()) {
+
+ }
+
+
+
+
+
+
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.scss b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.scss
new file mode 100644
index 00000000..df18259a
--- /dev/null
+++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.scss
@@ -0,0 +1,6 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+:host {
+ display: block;
+}
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
new file mode 100644
index 00000000..a9a77c92
--- /dev/null
+++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
@@ -0,0 +1,106 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { NavigationEnd, Router, RouterModule } from '@angular/router';
+import { AppService } from '@app/shared/services/app.service';
+import { SidebarComponent } from '@components/sidebar/sidebar.component';
+import { SidebarMenuItem } from '@lfx-one/shared/interfaces';
+import { filter } from 'rxjs';
+
+@Component({
+ selector: 'lfx-main-layout',
+ standalone: true,
+ imports: [CommonModule, RouterModule, SidebarComponent],
+ templateUrl: './main-layout.component.html',
+ styleUrl: './main-layout.component.scss',
+})
+export class MainLayoutComponent {
+ private readonly router = inject(Router);
+ private readonly appService = inject(AppService);
+
+ // Expose mobile sidebar state from service
+ protected readonly showMobileSidebar = this.appService.showMobileSidebar;
+
+ // Sidebar navigation items
+ protected readonly sidebarItems: SidebarMenuItem[] = [
+ {
+ label: 'Home',
+ icon: 'fa-light fa-house',
+ routerLink: '/',
+ },
+ {
+ label: 'My Meetings',
+ icon: 'fa-light fa-video',
+ routerLink: '/my-meetings',
+ disabled: true,
+ },
+ {
+ label: 'Project Health',
+ icon: 'fa-light fa-heart-pulse',
+ routerLink: '/project-health',
+ disabled: true,
+ },
+ {
+ label: 'Events & Community',
+ icon: 'fa-light fa-calendar',
+ routerLink: '/events-community',
+ disabled: true,
+ },
+ {
+ label: 'Training & Certification',
+ icon: 'fa-light fa-book-open',
+ routerLink: '/training-certification',
+ disabled: true,
+ },
+ ];
+
+ // Sidebar footer items
+ protected readonly sidebarFooterItems: SidebarMenuItem[] = [
+ {
+ label: 'Documentation',
+ icon: 'fa-light fa-file-lines',
+ url: 'https://docs.lfx.linuxfoundation.org',
+ },
+ {
+ label: 'Submit a Ticket',
+ icon: 'fa-light fa-circle-question',
+ url: 'https://jira.linuxfoundation.org/plugins/servlet/theme/portal/4',
+ },
+ {
+ label: 'Changelog',
+ icon: 'fa-light fa-rectangle-history',
+ routerLink: '/changelog',
+ disabled: true,
+ },
+ {
+ label: 'Settings',
+ icon: 'fa-light fa-gear',
+ routerLink: '/settings',
+ disabled: true,
+ },
+ {
+ label: 'Profile',
+ icon: 'fa-light fa-user',
+ routerLink: '/profile',
+ },
+ ];
+
+ public constructor() {
+ // Close mobile sidebar on navigation
+ this.router.events
+ .pipe(
+ filter((event) => event instanceof NavigationEnd),
+ takeUntilDestroyed()
+ )
+ .subscribe(() => {
+ this.appService.closeMobileSidebar();
+ });
+ }
+
+ public closeMobileSidebar(): void {
+ this.appService.closeMobileSidebar();
+ }
+}
diff --git a/apps/lfx-one/src/app/layouts/profile-layout/profile-layout.component.ts b/apps/lfx-one/src/app/layouts/profile-layout/profile-layout.component.ts
index 0d543a55..ca42a491 100644
--- a/apps/lfx-one/src/app/layouts/profile-layout/profile-layout.component.ts
+++ b/apps/lfx-one/src/app/layouts/profile-layout/profile-layout.component.ts
@@ -145,7 +145,7 @@ export class ProfileLayoutComponent {
const profile = this.profile();
if (!profile?.profile) return '';
- return profile.profile.job_title || '';
+ return profile.profile.title || '';
});
}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.html b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.html
new file mode 100644
index 00000000..02521b3a
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
My Meetings
+
+
+
+
+
+
+ @if (todayMeetings().length > 0 || upcomingMeetings().length > 0) {
+
+ @if (todayMeetings().length > 0) {
+
+
Today
+
+ @for (item of todayMeetings(); track item.meeting.uid) {
+
+ }
+
+
+ }
+
+
+ @if (upcomingMeetings().length > 0) {
+
+
Upcoming
+
+ @for (item of upcomingMeetings(); track item.meeting.uid) {
+
+ }
+
+
+ }
+ } @else {
+
+
+ No meetings scheduled
+
+ }
+
+
+
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.scss b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.scss
new file mode 100644
index 00000000..c32094b4
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.scss
@@ -0,0 +1,2 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.ts b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.ts
new file mode 100644
index 00000000..9c92c272
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-meetings/my-meetings.component.ts
@@ -0,0 +1,141 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component, computed, inject } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { Router } from '@angular/router';
+import { MeetingService } from '@app/shared/services/meeting.service';
+import { ButtonComponent } from '@components/button/button.component';
+import { DashboardMeetingCardComponent } from '@components/dashboard-meeting-card/dashboard-meeting-card.component';
+
+import type { Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces';
+
+interface MeetingWithOccurrence {
+ meeting: Meeting;
+ occurrence: MeetingOccurrence;
+ sortTime: number;
+}
+
+@Component({
+ selector: 'lfx-my-meetings',
+ standalone: true,
+ imports: [CommonModule, DashboardMeetingCardComponent, ButtonComponent],
+ templateUrl: './my-meetings.component.html',
+ styleUrl: './my-meetings.component.scss',
+})
+export class MyMeetingsComponent {
+ private readonly meetingService = inject(MeetingService);
+ private readonly router = inject(Router);
+ private readonly allMeetings = toSignal(this.meetingService.getMeetings(), { initialValue: [] });
+
+ protected readonly todayMeetings = computed(() => {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const todayEnd = new Date(today.getTime() + 24 * 60 * 60 * 1000);
+ const currentTime = now.getTime();
+ const buffer = 40 * 60 * 1000; // 40 minutes in milliseconds
+
+ const meetings: MeetingWithOccurrence[] = [];
+
+ for (const meeting of this.allMeetings()) {
+ // Process occurrences if they exist
+ if (meeting.occurrences && meeting.occurrences.length > 0) {
+ for (const occurrence of meeting.occurrences) {
+ const startTime = new Date(occurrence.start_time);
+ const startTimeMs = startTime.getTime();
+ const endTime = startTimeMs + occurrence.duration * 60 * 1000 + buffer;
+
+ // Include if meeting is today and hasn't ended yet (including buffer)
+ if (startTime >= today && startTime < todayEnd && endTime >= currentTime) {
+ meetings.push({
+ meeting,
+ occurrence,
+ sortTime: startTimeMs,
+ });
+ }
+ }
+ } else {
+ // Handle meetings without occurrences (single meetings)
+ const startTime = new Date(meeting.start_time);
+ const startTimeMs = startTime.getTime();
+ const endTime = startTimeMs + meeting.duration * 60 * 1000 + buffer;
+
+ // Include if meeting is today and hasn't ended yet (including buffer)
+ if (startTime >= today && startTime < todayEnd && endTime >= currentTime) {
+ meetings.push({
+ meeting,
+ occurrence: {
+ occurrence_id: '',
+ title: meeting.title,
+ description: meeting.description,
+ start_time: meeting.start_time,
+ duration: meeting.duration,
+ },
+ sortTime: startTimeMs,
+ });
+ }
+ }
+ }
+
+ // Sort by earliest time first
+ return meetings.sort((a, b) => a.sortTime - b.sortTime);
+ });
+
+ protected readonly upcomingMeetings = computed(() => {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const todayEnd = new Date(today.getTime() + 24 * 60 * 60 * 1000);
+
+ const meetings: MeetingWithOccurrence[] = [];
+
+ for (const meeting of this.allMeetings()) {
+ // Process occurrences if they exist
+ if (meeting.occurrences && meeting.occurrences.length > 0) {
+ for (const occurrence of meeting.occurrences) {
+ const startTime = new Date(occurrence.start_time);
+ const startTimeMs = startTime.getTime();
+
+ // Include if meeting is after today
+ if (startTime >= todayEnd) {
+ meetings.push({
+ meeting,
+ occurrence,
+ sortTime: startTimeMs,
+ });
+ }
+ }
+ } else {
+ // Handle meetings without occurrences (single meetings)
+ const startTime = new Date(meeting.start_time);
+ const startTimeMs = startTime.getTime();
+
+ // Include if meeting is after today
+ if (startTime >= todayEnd) {
+ meetings.push({
+ meeting,
+ occurrence: {
+ occurrence_id: '',
+ title: meeting.title,
+ description: meeting.description,
+ start_time: meeting.start_time,
+ duration: meeting.duration,
+ },
+ sortTime: startTimeMs,
+ });
+ }
+ }
+ }
+
+ // Sort by earliest time first and limit to 5
+ return meetings.sort((a, b) => a.sortTime - b.sortTime).slice(0, 5);
+ });
+
+ public handleSeeMeeting(meetingId: string): void {
+ this.router.navigate(['/meetings', meetingId]);
+ }
+
+ public handleViewAll(): void {
+ this.router.navigate(['/meetings']);
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.html b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.html
new file mode 100644
index 00000000..43de6589
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+
My Projects
+
+
+
+
+
+
+
+ | Project |
+ Affiliation(s) |
+
+
+ Code Activities
+
+
+ |
+
+
+ Non-Code Activities
+
+
+ |
+
+
+
+
+
+
+
+
+
+ @if (project.logo) {
+ ![]()
+ } @else {
+
+
+
+ }
+
+
+ {{ project.name }}
+ {{ project.role }}
+
+
+ |
+
+
+
+ {{ project.affiliations.join(', ') }}
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.scss b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.scss
new file mode 100644
index 00000000..c32094b4
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.scss
@@ -0,0 +1,2 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.ts b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.ts
new file mode 100644
index 00000000..45e2fd87
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/my-projects/my-projects.component.ts
@@ -0,0 +1,57 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component } from '@angular/core';
+import { ChartComponent } from '@components/chart/chart.component';
+import { TableComponent } from '@components/table/table.component';
+
+import type { ProjectItem } from '@lfx-one/shared/interfaces';
+
+@Component({
+ selector: 'lfx-my-projects',
+ standalone: true,
+ imports: [CommonModule, ChartComponent, TableComponent],
+ templateUrl: './my-projects.component.html',
+ styleUrl: './my-projects.component.scss',
+})
+export class MyProjectsComponent {
+ protected readonly projects: ProjectItem[] = [
+ {
+ name: 'Kubernetes',
+ logo: 'https://avatars.githubusercontent.com/u/13455738?s=280&v=4',
+ role: 'Maintainer',
+ affiliations: ['CNCF', 'Google'],
+ codeActivities: [28, 32, 30, 35, 38, 40, 42],
+ nonCodeActivities: [8, 10, 12, 11, 13, 14, 15],
+ status: 'active',
+ },
+ {
+ name: 'Linux Kernel',
+ logo: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
+ role: 'Contributor',
+ affiliations: ['Linux Foundation'],
+ codeActivities: [15, 18, 20, 22, 24, 26, 28],
+ nonCodeActivities: [3, 4, 5, 6, 7, 7, 8],
+ status: 'active',
+ },
+ {
+ name: 'Node.js',
+ logo: 'https://nodejs.org/static/logos/nodejsHex.svg',
+ role: 'Reviewer',
+ affiliations: ['OpenJS Foundation'],
+ codeActivities: [18, 16, 15, 14, 13, 12, 12],
+ nonCodeActivities: [8, 7, 6, 6, 5, 5, 5],
+ status: 'archived',
+ },
+ ];
+
+ /**
+ * Generates labels for chart based on data length
+ * @param length - Number of data points
+ * @returns Array of empty strings for chart labels
+ */
+ protected generateLabels(length: number): string[] {
+ return Array.from({ length }, () => '');
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.html b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.html
new file mode 100644
index 00000000..893de263
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
Pending Actions
+
+
+
+
+
+
+ @for (item of pendingActions; track item.text) {
+
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+
+ {{ item.badge }}
+
+
+
+
+
+
+
+
+
+ }
+
+
+
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.scss b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.scss
new file mode 100644
index 00000000..c32094b4
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.scss
@@ -0,0 +1,2 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.ts b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.ts
new file mode 100644
index 00000000..9b461721
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/pending-actions/pending-actions.component.ts
@@ -0,0 +1,54 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component, output } from '@angular/core';
+import { ButtonComponent } from '@components/button/button.component';
+import type { PendingActionItem } from '@lfx-one/shared/interfaces';
+
+@Component({
+ selector: 'lfx-pending-actions',
+ standalone: true,
+ imports: [CommonModule, ButtonComponent],
+ templateUrl: './pending-actions.component.html',
+ styleUrl: './pending-actions.component.scss',
+})
+export class PendingActionsComponent {
+ public readonly actionClick = output();
+ public readonly viewAll = output();
+
+ protected readonly pendingActions: PendingActionItem[] = [
+ {
+ type: 'Issue',
+ badge: 'Kubernetes',
+ text: 'Maintainer tagged you for clarification on issue #238: Pod Autoscaler UI.',
+ icon: 'fa-light fa-envelope',
+ color: 'amber',
+ buttonText: 'Add Comment',
+ },
+ {
+ type: 'PR Review',
+ badge: 'Linux Kernel',
+ text: 'Code review requested for pull request #456: Memory management optimization.',
+ icon: 'fa-light fa-code-pull-request',
+ color: 'blue',
+ buttonText: 'Review PR',
+ },
+ {
+ type: 'Meeting',
+ badge: 'CNCF',
+ text: 'Technical Steering Committee meeting agenda review needed by EOD.',
+ icon: 'fa-light fa-calendar',
+ color: 'green',
+ buttonText: 'View Agenda',
+ },
+ ];
+
+ public handleViewAll(): void {
+ this.viewAll.emit();
+ }
+
+ protected handleActionClick(item: PendingActionItem): void {
+ this.actionClick.emit(item);
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.html b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.html
new file mode 100644
index 00000000..942ccdb9
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
Recent Progress
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.scss b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.scss
new file mode 100644
index 00000000..121c9727
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.scss
@@ -0,0 +1,11 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+.hide-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+
+ &::-webkit-scrollbar {
+ display: none; /* Chrome, Safari and Opera */
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.ts b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.ts
new file mode 100644
index 00000000..58202a55
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/components/recent-progress/recent-progress.component.ts
@@ -0,0 +1,182 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component, ElementRef, ViewChild } from '@angular/core';
+import { ChartComponent } from '@components/chart/chart.component';
+
+import type { ProgressItemWithChart } from '@lfx-one/shared/interfaces';
+
+@Component({
+ selector: 'lfx-recent-progress',
+ standalone: true,
+ imports: [CommonModule, ChartComponent],
+ templateUrl: './recent-progress.component.html',
+ styleUrl: './recent-progress.component.scss',
+})
+export class RecentProgressComponent {
+ @ViewChild('progressScroll') protected progressScrollContainer!: ElementRef;
+
+ protected readonly progressItems: ProgressItemWithChart[] = [
+ {
+ label: 'Code Commits',
+ value: '47',
+ trend: 'up',
+ subtitle: 'Last 30 days',
+ chartData: {
+ labels: Array.from({ length: 30 }, (_, i) => `Day ${i + 1}`),
+ datasets: [
+ {
+ data: Array.from({ length: 30 }, () => Math.floor(Math.random() * 6)),
+ borderColor: '#0094FF',
+ backgroundColor: 'rgba(0, 148, 255, 0.1)',
+ fill: true,
+ tension: 0.4,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ chartOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
+ scales: {
+ x: { display: false },
+ y: { display: false },
+ },
+ },
+ },
+ {
+ label: 'Pull Requests Merged',
+ value: '12',
+ trend: 'up',
+ subtitle: 'Last 30 days',
+ chartData: {
+ labels: Array.from({ length: 30 }, (_, i) => `Day ${i + 1}`),
+ datasets: [
+ {
+ data: Array.from({ length: 30 }, () => Math.floor(Math.random() * 3)),
+ borderColor: '#0094FF',
+ backgroundColor: 'rgba(0, 148, 255, 0.1)',
+ fill: true,
+ tension: 0.4,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ chartOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
+ scales: {
+ x: { display: false },
+ y: { display: false },
+ },
+ },
+ },
+ {
+ label: 'Issues Resolved & Comments Added',
+ value: '34',
+ trend: 'up',
+ subtitle: 'Combined activity last 30 days',
+ chartData: {
+ labels: Array.from({ length: 30 }, (_, i) => `Day ${i + 1}`),
+ datasets: [
+ {
+ data: Array.from({ length: 30 }, () => Math.floor(Math.random() * 5)),
+ borderColor: '#0094FF',
+ backgroundColor: 'rgba(0, 148, 255, 0.1)',
+ fill: true,
+ tension: 0.4,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ chartOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
+ scales: {
+ x: { display: false },
+ y: { display: false },
+ },
+ },
+ },
+ {
+ label: 'Active Weeks Streak',
+ value: '12',
+ trend: 'up',
+ subtitle: 'Current streak',
+ chartData: {
+ labels: Array.from({ length: 20 }, (_, i) => `Week ${i + 1}`),
+ datasets: [
+ {
+ data: Array.from({ length: 20 }, (_, i) => {
+ if (i >= 8) {
+ return 1;
+ }
+ return Math.random() > 0.5 ? 1 : 0;
+ }),
+ borderColor: '#10b981',
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
+ fill: true,
+ tension: 0.4,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ chartOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
+ scales: {
+ x: { display: false },
+ y: { display: false },
+ },
+ },
+ },
+ {
+ label: 'Learning Hours',
+ value: '8.5',
+ trend: 'up',
+ subtitle: 'Last 30 days',
+ chartData: {
+ labels: Array.from({ length: 30 }, (_, i) => `Day ${i + 1}`),
+ datasets: [
+ {
+ data: Array.from({ length: 30 }, () => Math.floor(Math.random() * 3)),
+ borderColor: '#93c5fd',
+ backgroundColor: 'rgba(147, 197, 253, 0.1)',
+ fill: true,
+ tension: 0.4,
+ borderWidth: 2,
+ pointRadius: 0,
+ },
+ ],
+ },
+ chartOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
+ scales: {
+ x: { display: false },
+ y: { display: false },
+ },
+ },
+ },
+ ];
+
+ protected scrollLeft(): void {
+ const container = this.progressScrollContainer.nativeElement;
+ container.scrollBy({ left: -300, behavior: 'smooth' });
+ }
+
+ protected scrollRight(): void {
+ const container = this.progressScrollContainer.nativeElement;
+ container.scrollBy({ left: 300, behavior: 'smooth' });
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.html b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.html
new file mode 100644
index 00000000..61900393
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.scss b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.scss
new file mode 100644
index 00000000..c32094b4
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.scss
@@ -0,0 +1,2 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
diff --git a/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.ts b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.ts
new file mode 100644
index 00000000..c0976150
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/pages/dashboard/dashboard.component.ts
@@ -0,0 +1,17 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Component } from '@angular/core';
+import { MyMeetingsComponent } from './components/my-meetings/my-meetings.component';
+import { MyProjectsComponent } from './components/my-projects/my-projects.component';
+import { PendingActionsComponent } from './components/pending-actions/pending-actions.component';
+import { RecentProgressComponent } from './components/recent-progress/recent-progress.component';
+
+@Component({
+ selector: 'lfx-dashboard',
+ standalone: true,
+ imports: [RecentProgressComponent, PendingActionsComponent, MyMeetingsComponent, MyProjectsComponent],
+ templateUrl: './dashboard.component.html',
+ styleUrl: './dashboard.component.scss',
+})
+export class DashboardComponent {}
diff --git a/apps/lfx-one/src/app/modules/profile/edit/profile-edit.component.html b/apps/lfx-one/src/app/modules/profile/edit/profile-edit.component.html
index 9a0cfd6b..50a9e576 100644
--- a/apps/lfx-one/src/app/modules/profile/edit/profile-edit.component.html
+++ b/apps/lfx-one/src/app/modules/profile/edit/profile-edit.component.html
@@ -97,7 +97,7 @@ = {
given_name: formValue.given_name || undefined,
family_name: formValue.family_name || undefined,
- job_title: formValue.job_title || undefined,
+ title: formValue.title || undefined,
organization: formValue.organization || undefined,
country: formValue.country || undefined,
state_province: formValue.state_province || undefined,
@@ -224,7 +224,7 @@ export class ProfileEditComponent implements OnInit {
given_name: profile.user.first_name || '',
family_name: profile.user.last_name || '',
username: profile.user.username || '',
- job_title: profile.profile?.job_title || '',
+ title: profile.profile?.title || '',
organization: profile.profile?.organization || '',
country: countryValue,
state_province: profile.profile?.state_province || '',
diff --git a/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.html b/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.html
new file mode 100644
index 00000000..80c548e2
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+ {{ meetingTypeInfo().label }}
+
+ @if (meeting().project_name) {
+
+ {{ meeting().project_name }}
+
+ }
+
+
+ @if (hasYoutubeUploads()) {
+
+
+
+ }
+ @if (hasRecording()) {
+
+
+
+ }
+ @if (hasTranscripts()) {
+
+
+
+ }
+ @if (hasAiSummary()) {
+
+
+
+ }
+ @if (!isPrivate()) {
+
+
+
+ }
+
+
+
+
+
+
+ {{ meetingTitle() }}
+
+
+
+
+
+
+
+
+
+ {{ formattedTime() }}
+
+
+
+
+
+ @if (isTodayMeeting()) {
+
+ }
+
+
+
diff --git a/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.ts b/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.ts
new file mode 100644
index 00000000..689daea7
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/components/dashboard-meeting-card/dashboard-meeting-card.component.ts
@@ -0,0 +1,142 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { CommonModule } from '@angular/common';
+import { Component, computed, input, output, Signal } from '@angular/core';
+import { ButtonComponent } from '@components/button/button.component';
+import { Meeting, MeetingOccurrence } from '@lfx-one/shared';
+import { TooltipModule } from 'primeng/tooltip';
+
+interface MeetingTypeBadge {
+ label: string;
+ className: string;
+}
+
+@Component({
+ selector: 'lfx-dashboard-meeting-card',
+ standalone: true,
+ imports: [CommonModule, ButtonComponent, TooltipModule],
+ templateUrl: './dashboard-meeting-card.component.html',
+})
+export class DashboardMeetingCardComponent {
+ public readonly meeting = input.required();
+ public readonly occurrence = input(null);
+ public readonly onSeeMeeting = output();
+
+ // Computed values
+ public readonly meetingTypeInfo: Signal = computed(() => {
+ const type = this.meeting().meeting_type?.toLowerCase();
+
+ switch (type) {
+ case 'technical':
+ return { label: 'Technical', className: 'bg-purple-100 text-purple-600' };
+ case 'maintainers':
+ return { label: 'Maintainers', className: 'bg-blue-100 text-blue-600' };
+ case 'board':
+ return { label: 'Board', className: 'bg-red-100 text-red-600' };
+ case 'marketing':
+ return { label: 'Marketing', className: 'bg-green-100 text-green-600' };
+ case 'legal':
+ return { label: 'Legal', className: 'bg-amber-100 text-amber-600' };
+ case 'other':
+ return { label: 'Other', className: 'bg-gray-100 text-gray-600' };
+ default:
+ return { label: 'Meeting', className: 'bg-gray-100 text-gray-400' };
+ }
+ });
+
+ public readonly meetingStartTime: Signal = computed(() => {
+ const occurrence = this.occurrence();
+ const meeting = this.meeting();
+
+ // Use occurrence start time if available, otherwise use meeting start time
+ return occurrence?.start_time || meeting.start_time;
+ });
+
+ public readonly formattedTime: Signal = computed(() => {
+ const startTime = this.meetingStartTime();
+
+ try {
+ const meetingDate = new Date(startTime);
+
+ if (isNaN(meetingDate.getTime())) {
+ return startTime;
+ }
+
+ const today = new Date();
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+
+ const isToday = meetingDate.toDateString() === today.toDateString();
+ const isTomorrow = meetingDate.toDateString() === tomorrow.toDateString();
+
+ const timeStr = meetingDate.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+
+ if (isToday) {
+ return `Today, ${timeStr}`;
+ } else if (isTomorrow) {
+ return `Tomorrow, ${timeStr}`;
+ }
+ const dateStr = meetingDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+ return `${dateStr} at ${timeStr}`;
+ } catch {
+ return startTime;
+ }
+ });
+
+ public readonly isTodayMeeting: Signal = computed(() => {
+ const startTime = this.meetingStartTime();
+
+ try {
+ const meetingDate = new Date(startTime);
+
+ if (isNaN(meetingDate.getTime())) {
+ return false;
+ }
+
+ const today = new Date();
+ return meetingDate.toDateString() === today.toDateString();
+ } catch {
+ return false;
+ }
+ });
+
+ public readonly isPrivate: Signal = computed(() => {
+ return this.meeting().visibility === 'private';
+ });
+
+ public readonly hasYoutubeUploads: Signal = computed(() => {
+ return this.meeting().youtube_upload_enabled === true;
+ });
+
+ public readonly hasRecording: Signal = computed(() => {
+ return this.meeting().recording_enabled === true;
+ });
+
+ public readonly hasTranscripts: Signal = computed(() => {
+ return this.meeting().transcript_enabled === true;
+ });
+
+ public readonly hasAiSummary: Signal = computed(() => {
+ return this.meeting().zoom_config?.ai_companion_enabled === true;
+ });
+
+ public readonly meetingTitle: Signal = computed(() => {
+ const occurrence = this.occurrence();
+ const meeting = this.meeting();
+
+ // Use occurrence title if available, otherwise use meeting title
+ return occurrence?.title || meeting.title;
+ });
+
+ public handleSeeMeeting(): void {
+ this.onSeeMeeting.emit(this.meeting().uid);
+ }
+}
diff --git a/apps/lfx-one/src/app/shared/components/header/header.component.html b/apps/lfx-one/src/app/shared/components/header/header.component.html
index b1fe331a..e0c47264 100644
--- a/apps/lfx-one/src/app/shared/components/header/header.component.html
+++ b/apps/lfx-one/src/app/shared/components/header/header.component.html
@@ -2,20 +2,51 @@
-
+
-
+
+
+
+
+
+
+ @if (userService.authenticated()) {
+
+
+
+
+
+ {{ userProfile()?.user?.first_name }} {{ userProfile()?.user?.last_name }}
+ {{ userProfile()?.profile?.title }} at {{ userProfile()?.profile?.organization }}
+
+
+ }
+
-
+