diff --git a/.agent/rules/angular-material-expert.md b/.agent/rules/angular-material-expert.md index c649fd923..1fc5a4888 100644 --- a/.agent/rules/angular-material-expert.md +++ b/.agent/rules/angular-material-expert.md @@ -1,195 +1,34 @@ --- trigger: model_decision -description: Use this rule when you are working with material design +description: Use for Angular Material component selection, theming, and Material-first UI reviews. --- -You are an expert in Angular Material, the official Material Design component library for Angular. You specialize in ensuring teams use Angular Material components correctly and avoid reinventing the wheel with custom implementations. - -## Core Responsibilities - -When reviewing code for Angular Material usage, you will: - -### 1. Component Selection -- **Identify Custom Components**: Look for custom UI components that Angular Material already provides -- **Suggest Material Alternatives**: Recommend appropriate Angular Material components -- **Justify Custom**: Accept custom components only when Angular Material truly lacks the functionality -- **Component Variants**: Ensure proper component variants are used (raised, flat, stroked, icon buttons) - -### 2. Common Angular Material Components to Prefer - -**Layout & Structure**: -- `MatToolbar` for headers -- `MatSidenav` for side navigation -- `MatCard` for content containers -- `MatDivider` for visual separation -- `MatGridList` for grid layouts (though CSS Grid is often preferred) -- `MatExpansionPanel` for collapsible content - -**Data Entry**: -- `MatFormField` wrapper for all inputs -- `MatInput` for text inputs -- `MatSelect` for dropdowns -- `MatCheckbox`, `MatRadio`, `MatSlideToggle` for selection controls -- `MatDatepicker` for date selection -- `MatSlider` for range selection -- `MatAutocomplete` for search/autocomplete -- `MatChips` for tagging/filtering - -**Data Display**: -- `MatTable` for data tables (with sorting, filtering, pagination) -- `MatList` for list views -- `MatTree` for hierarchical data -- `MatBadge` for counts/status -- `MatIcon` for icons (Material Icons) -- `MatTooltip` for hover information - -**Feedback**: -- `MatDialog` for modals/confirmations -- `MatSnackBar` for toast notifications -- `MatProgressBar` and `MatSpinner` for loading states -- `MatBottomSheet` for mobile-friendly actions - -**Navigation**: -- `MatMenu` for dropdown menus -- `MatTabs` for tabbed interfaces -- `MatStepper` for multi-step processes -- `MatPaginator` for pagination - -**Buttons**: -- `MatButton` (basic, raised, stroked, flat) -- `MatIconButton` for icon-only buttons -- `MatFab` and `MatMiniFab` for floating action buttons - -### 3. Angular Material Patterns - -**Form Handling**: -```typescript -// ✅ GOOD: Use MatFormField with MatInput and MatError - - Email - - @if (emailControl.hasError('email')) { - Please enter a valid email address - } - - -// ❌ BAD: Custom form with manual styling -
- - - Invalid email -
-``` - -**Dialogs**: -```typescript -// ✅ GOOD: Use MatDialog service -this.dialog.open(DeleteConfirmationDialog, { - data: { item: this.item } -}); - -// ❌ BAD: Custom modal component in template - - ... - -``` - -**SnackBars**: -```typescript -// ✅ GOOD: Use MatSnackBar service -this.snackBar.open('Item saved', 'Close', { duration: 3000 }); - -// ❌ BAD: Custom toast component -{{ message }} -``` - -### 4. Theming & Styling - -- **Theming System**: Ensure proper use of Angular Material's theming system (palettes, typography) -- **SCSS Mixins**: Use `@use '@angular/material' as mat;` and theme mixins -- **Color Usage**: Use `mat.get-color-from-palette` or CSS variables derived from the theme -- **Density**: Utilize density subsystems for compact layouts -- **Custom Styles**: Override styles using specific classes, avoiding `::ng-deep` where possible (or using it carefully within encapsulated components) - -### 5. Responsive Design - -- **Breakpoints**: Use `@angular/cdk/layout` `BreakpointObserver` for responsive logic -- **Project Breakpoints**: - - **Mobile**: `max-width: 768px` - - **Container**: `max-width: 1200px` (use `.page-container` or similar) -- **Flex Layout**: While `flex-layout` is deprecated, use standard CSS Flexbox/Grid with Material components -- **Mobile Support**: Ensure components like `MatSidenav` and `MatDialog` behave correctly on mobile - -### 6. Accessibility - -- **Built-in A11y**: Leverage Angular Material's built-in accessibility features (ARIA, focus management) -- **CDK A11y**: Use `@angular/cdk/a11y` for focus trapping, live announcers, etc. -- **Keyboard Navigation**: Ensure tab order and keyboard interaction work as expected - -### 7. Icons - -- **MatIcon**: Use `` with Material Icons font or SVG icons -- **Registry**: Use `MatIconRegistry` for custom SVG icons - -### 8. Common Anti-Patterns to Catch - -- ❌ Using native ` - + - @@ -31,4 +31,4 @@ Delete } - \ No newline at end of file + diff --git a/src/app/components/activity-actions/activity.actions.component.spec.ts b/src/app/components/activity-actions/activity.actions.component.spec.ts index d1746672c..ab0ae53d7 100644 --- a/src/app/components/activity-actions/activity.actions.component.spec.ts +++ b/src/app/components/activity-actions/activity.actions.component.spec.ts @@ -2,22 +2,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivityActionsComponent } from './activity.actions.component'; import { AppEventService } from '../../services/app.event.service'; -import { MatDialogModule } from '@angular/material/dialog'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatMenuModule } from '@angular/material/menu'; import { MatIconModule } from '@angular/material/icon'; import { MatDividerModule } from '@angular/material/divider'; import { MatButtonModule } from '@angular/material/button'; import { ChangeDetectorRef } from '@angular/core'; -import { ActivityInterface, EventInterface, EventUtilities } from '@sports-alliance/sports-lib'; -import { of } from 'rxjs'; +import { AppEventReprocessService, ReprocessError } from '../../services/app.event-reprocess.service'; +import { AppProcessingService } from '../../services/app.processing.service'; import { RouterTestingModule } from '@angular/router/testing'; -import { vi } from 'vitest'; +import { of } from 'rxjs'; +import { vi, describe, beforeEach, it, expect } from 'vitest'; describe('ActivityActionsComponent', () => { let component: ActivityActionsComponent; let fixture: ComponentFixture; let eventServiceMock: any; + let eventReprocessServiceMock: any; + let processingServiceMock: any; + let dialogMock: any; let eventMock: any; let activityMock: any; let userMock: any; @@ -45,10 +49,25 @@ describe('ActivityActionsComponent', () => { // Mock AppEventService eventServiceMock = { - attachStreamsToEventWithActivities: vi.fn(), writeAllEventData: vi.fn().mockResolvedValue(true), deleteAllActivityData: vi.fn().mockResolvedValue(true), }; + eventReprocessServiceMock = { + regenerateActivityStatistics: vi.fn().mockResolvedValue({ + updatedActivityId: 'activity-1', + }), + }; + processingServiceMock = { + addJob: vi.fn().mockReturnValue('job-id'), + updateJob: vi.fn(), + completeJob: vi.fn(), + failJob: vi.fn(), + }; + dialogMock = { + open: vi.fn().mockReturnValue({ + afterClosed: () => of(true), + }), + }; await TestBed.configureTestingModule({ declarations: [ActivityActionsComponent], @@ -63,6 +82,9 @@ describe('ActivityActionsComponent', () => { ], providers: [ { provide: AppEventService, useValue: eventServiceMock }, + { provide: AppEventReprocessService, useValue: eventReprocessServiceMock }, + { provide: AppProcessingService, useValue: processingServiceMock }, + { provide: MatDialog, useValue: dialogMock }, ChangeDetectorRef ] }).compileComponents(); @@ -80,27 +102,36 @@ describe('ActivityActionsComponent', () => { }); describe('reGenerateStatistics', () => { - it('should call attachStreamsToEventWithActivities, reGenerateStatsForEvent, and writeAllEventData', async () => { - // Arrange - const freshActivityMock = { - getID: () => 'activity-1', - getAllStreams: () => [], - }; - const freshEventMock = { - getActivities: () => [freshActivityMock], - getID: () => 'event-1' - }; - - eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(freshEventMock)); - const reGenerateStatsSpy = vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); - - // Act + it('should delegate to AppEventReprocessService and complete processing job', async () => { + await component.reGenerateStatistics(); + expect(eventReprocessServiceMock.regenerateActivityStatistics).toHaveBeenCalledWith( + userMock, + eventMock, + 'activity-1', + expect.objectContaining({ onProgress: expect.any(Function) }), + ); + expect(processingServiceMock.completeJob).toHaveBeenCalled(); + }); + + it('should do nothing when confirmation is cancelled', async () => { + dialogMock.open.mockReturnValueOnce({ + afterClosed: () => of(false), + }); + + await component.reGenerateStatistics(); + + expect(eventReprocessServiceMock.regenerateActivityStatistics).not.toHaveBeenCalled(); + expect(processingServiceMock.addJob).not.toHaveBeenCalled(); + }); + + it('should fail processing job on reprocess failure', async () => { + eventReprocessServiceMock.regenerateActivityStatistics.mockRejectedValueOnce( + new ReprocessError('PARSE_FAILED', 'parse failed'), + ); + await component.reGenerateStatistics(); - // Assert - expect(eventServiceMock.attachStreamsToEventWithActivities).toHaveBeenCalledWith(userMock, eventMock); - expect(reGenerateStatsSpy).toHaveBeenCalledWith(eventMock); - expect(eventServiceMock.writeAllEventData).toHaveBeenCalledWith(userMock, eventMock); + expect(processingServiceMock.failJob).toHaveBeenCalled(); }); }); }); diff --git a/src/app/components/activity-actions/activity.actions.component.ts b/src/app/components/activity-actions/activity.actions.component.ts index 14931622f..1cc155094 100644 --- a/src/app/components/activity-actions/activity.actions.component.ts +++ b/src/app/components/activity-actions/activity.actions.component.ts @@ -8,10 +8,16 @@ import { ActivityInterface } from '@sports-alliance/sports-lib'; import { ActivityFormComponent } from '../activity-form/activity.form.component'; import { User } from '@sports-alliance/sports-lib'; import { EventUtilities } from '@sports-alliance/sports-lib'; -import { take } from 'rxjs/operators'; -import { DeleteConfirmationComponent } from '../delete-confirmation/delete-confirmation.component'; +import { firstValueFrom } from 'rxjs'; +import { ConfirmationDialogComponent, ConfirmationDialogData } from '../confirmation-dialog/confirmation-dialog.component'; import { DataDistance } from '@sports-alliance/sports-lib'; -import { ActivityUtilities } from '@sports-alliance/sports-lib'; +import { + AppEventReprocessService, + ReprocessError, + ReprocessPhase, + ReprocessProgress +} from '../../services/app.event-reprocess.service'; +import { AppProcessingService } from '../../services/app.processing.service'; @Component({ selector: 'app-activity-actions', @@ -30,6 +36,8 @@ export class ActivityActionsComponent implements OnInit, OnDestroy { constructor( private eventService: AppEventService, + private eventReprocessService: AppEventReprocessService, + private processingService: AppProcessingService, private changeDetectorRef: ChangeDetectorRef, private router: Router, private snackBar: MatSnackBar, @@ -62,31 +70,59 @@ export class ActivityActionsComponent implements OnInit, OnDestroy { } async reGenerateStatistics() { - this.snackBar.open('Re-calculating activity statistics', undefined, { - duration: 2000, + const confirmed = await this.confirmReprocessAction({ + title: 'Regenerate activity statistics?', + message: 'This will re-calculate statistics like distance, ascent, descent etc...', + confirmLabel: 'Regenerate', + confirmColor: 'primary', }); - // We re-parse original file(s) to get the most accurate streams and statistics. - // This replaces activities in this.event with fresh ones from the parser. - await this.eventService.attachStreamsToEventWithActivities(this.user, this.event as any).pipe(take(1)).toPromise(); - - // Update local activity reference to the newly parsed one - const newActivity = this.event.getActivities().find(a => a.getID() === this.activity.getID()); - if (newActivity) { - this.activity = newActivity; + if (!confirmed) { + return; } - // Refresh event-level stats from the new activity - EventUtilities.reGenerateStatsForEvent(this.event); - - await this.eventService.writeAllEventData(this.user, this.event); - this.snackBar.open('Activity and event statistics have been recalculated', undefined, { + this.snackBar.open('Re-calculating activity statistics', undefined, { duration: 2000, }); - this.changeDetectorRef.detectChanges(); + const jobId = this.processingService.addJob('process', 'Re-calculating activity statistics...'); + this.processingService.updateJob(jobId, { status: 'processing', progress: 5 }); + + try { + const result = await this.eventReprocessService.regenerateActivityStatistics( + this.user, + this.event as any, + this.activity.getID(), + { + onProgress: (progress) => this.updateReprocessJob(jobId, progress), + }, + ); + const updatedActivityId = result.updatedActivityId || this.activity.getID(); + const updatedActivity = this.event.getActivities().find(activity => activity.getID() === updatedActivityId); + if (updatedActivity) { + this.activity = updatedActivity; + } + this.processingService.completeJob(jobId, 'Activity and event statistics recalculated'); + this.snackBar.open('Activity and event statistics have been recalculated', undefined, { + duration: 2000, + }); + this.changeDetectorRef.detectChanges(); + } catch (error) { + this.processingService.failJob(jobId, 'Re-calculation failed'); + this.snackBar.open(this.getReprocessErrorMessage(error, 'Could not recalculate statistics.'), undefined, { + duration: 4000, + }); + } } async deleteActivity() { - const dialogRef = this.dialog.open(DeleteConfirmationComponent); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Are you sure you want to delete?', + message: 'All data will be permanently deleted. This operation cannot be undone.', + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + confirmColor: 'warn', + } as ConfirmationDialogData, + }); this.deleteConfirmationSubscription = dialogRef.afterClosed().subscribe(async (result) => { if (!result) { return; @@ -105,6 +141,65 @@ export class ActivityActionsComponent implements OnInit, OnDestroy { // @todo: Implement crop activity } + private updateReprocessJob(jobId: string, progress: ReprocessProgress): void { + this.processingService.updateJob(jobId, { + status: progress.phase === 'done' ? 'completed' : 'processing', + title: this.getReprocessTitle(progress.phase), + progress: progress.progress, + details: progress.details, + }); + } + + private getReprocessTitle(phase: ReprocessPhase): string { + switch (phase) { + case 'validating': + return 'Validating source files...'; + case 'downloading': + return 'Downloading source files...'; + case 'parsing': + return 'Parsing source files...'; + case 'merging': + return 'Merging parsed activities...'; + case 'regenerating_stats': + return 'Generating statistics...'; + case 'persisting': + return 'Saving event...'; + case 'done': + return 'Done'; + default: + return 'Processing...'; + } + } + + private getReprocessErrorMessage(error: unknown, fallback: string): string { + if (error instanceof ReprocessError) { + if (error.code === 'NO_ORIGINAL_FILES') { + return 'No original source files found for this event.'; + } + if (error.code === 'PARSE_FAILED') { + return 'Could not parse the original source file.'; + } + if (error.code === 'ACTIVITY_NOT_FOUND_AFTER_REHYDRATE') { + return 'The selected activity could not be matched after rehydration.'; + } + if (error.code === 'PERSIST_FAILED') { + return 'Could not save the updated event after reprocessing.'; + } + } + return fallback; + } + + private async confirmReprocessAction(dialogData: ConfirmationDialogData): Promise { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + cancelLabel: 'Cancel', + ...dialogData, + } as ConfirmationDialogData, + }); + const confirmed = await firstValueFrom(dialogRef.afterClosed()); + return confirmed === true; + } + ngOnDestroy(): void { if (this.deleteConfirmationSubscription) { this.deleteConfirmationSubscription.unsubscribe() diff --git a/src/app/components/activity-form/activity.form.component.html b/src/app/components/activity-form/activity.form.component.html index 22b30b874..bd5b852c7 100644 --- a/src/app/components/activity-form/activity.form.component.html +++ b/src/app/components/activity-form/activity.form.component.html @@ -1,133 +1,134 @@ @if (activityFormGroup) {
- - Editing activity - -
- Type - - - @for (activityType of activityTypesArray; track activityType) { - - {{activityType}} - + + Editing activity + +
+ Type + + + @for (activityType of activityTypesArray; track activityType) { + + {{activityType}} + + } + + +
+
+ Device + + + How would you label/name this device that the activity was made with + @if (hasError('creatorName')) { + + invalid name + } - - -
-
- Device - - - How would you label/name this device that the activity was made with - @if (hasError('creatorName')) { - - invalid name - - } - -
-
- Date - - - - - @if (hasError('startDate')) { - - invalid start date - - } - - - - @if (hasError('startTime')) { - - invalid start time - - } - - - - - - @if (hasError('endDate')) { - - invalid end date - - } - - - - @if (hasError('endTime')) { - - invalid end time - + +
+
+ Date + + + + + @if (hasError('startDate')) { + + invalid start date + + } + + + + @if (hasError('startTime')) { + + invalid start time + + } + + + + + + @if (hasError('endDate')) { + + invalid end date + + } + + + + @if (hasError('endTime')) { + + invalid end time + + } + +
+
+ Stats + @if (activityFormGroup.get('ascent')) { + + + m   + @if (hasError('ascent')) { + + invalid ascent + + } + } - -
-
- Stats - @if (activityFormGroup.get('ascent')) { - - - m   - @if (hasError('ascent')) { - - invalid ascent - + @if (activityFormGroup.get('descent')) { + + + m   + @if (hasError('descent')) { + + invalid descent + + } + } - - } - @if (activityFormGroup.get('descent')) { - - - m   - @if (hasError('descent')) { - - invalid descent - + @if (activityFormGroup.get('energy')) { + + + KCal   + @if (hasError('energy')) { + + invalid energy + + } + } - - } - @if (activityFormGroup.get('energy')) { - - - KCal   - @if (hasError('energy')) { - - invalid energy - + @if (activityFormGroup.get('distance')) { + + + m   + @if (hasError('distance')) { + + invalid distance + + } + } - +
+ @if (hasError()) { + Something does not seem logical above 🤔 } - @if (activityFormGroup.get('distance')) { - - - m   - @if (hasError('distance')) { - - invalid distance - +
+ + @if (!hasError()) { + } - - } -
- @if (hasError()) { - Something does not seem logical above 🤔 - } -
- - @if (!hasError()) { - - } -
+ +
} -
\ No newline at end of file + diff --git a/src/app/components/activity-form/activity.form.component.ts b/src/app/components/activity-form/activity.form.component.ts index 2c476516e..4b1bb57ea 100644 --- a/src/app/components/activity-form/activity.form.component.ts +++ b/src/app/components/activity-form/activity.form.component.ts @@ -214,8 +214,7 @@ export class ActivityFormComponent implements OnInit { this.event.addStat(new DataActivityTypes(this.event.getActivities().map(activity => activity.type))); } - await this.eventService.setActivity(this.user, this.event, this.activity); - await this.eventService.setEvent(this.user, this.event); + await this.eventService.writeActivityAndEventData(this.user, this.event, this.activity); this.snackBar.open('Activity saved', undefined, { duration: 2000, diff --git a/src/app/components/admin/admin-changelog/admin-changelog.component.scss b/src/app/components/admin/admin-changelog/admin-changelog.component.scss index 647697e4c..0d874d46b 100644 --- a/src/app/components/admin/admin-changelog/admin-changelog.component.scss +++ b/src/app/components/admin/admin-changelog/admin-changelog.component.scss @@ -72,21 +72,6 @@ } } -.glass-card { - background: var(--mat-sys-surface); - border-radius: 16px; - padding: 1.5rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); - border: 1px solid var(--mat-sys-outline-variant); - backdrop-filter: blur(10px); - margin-bottom: 24px; - - :host-context(.dark-theme) & { - background: rgba(var(--mat-sys-surface-rgb), 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - } -} - // Custom Form Row override for specifically 3-item row .form-row { display: flex; @@ -235,4 +220,4 @@ td.mat-cell { opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/app/components/admin/admin-dashboard/admin-dashboard.component.scss b/src/app/components/admin/admin-dashboard/admin-dashboard.component.scss index 7614174e6..eea088ac8 100644 --- a/src/app/components/admin/admin-dashboard/admin-dashboard.component.scss +++ b/src/app/components/admin/admin-dashboard/admin-dashboard.component.scss @@ -95,20 +95,6 @@ } } -.glass-card { - background: var(--mat-sys-surface, white); // Fallback - border-radius: 12px; - padding: 1.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - - :host-context(.dark-theme) & { - background: rgba(30, 30, 30, 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - } -} - // Chart Styles (Pie Chart still remains in dashboard?) // Check if Auth Pie chart uses these .chart-header { @@ -301,4 +287,4 @@ td.mat-cell { .search-field { width: 100%; font-size: 0.9rem; -} \ No newline at end of file +} diff --git a/src/app/components/admin/admin-dashboard/admin-dashboard.component.spec.ts b/src/app/components/admin/admin-dashboard/admin-dashboard.component.spec.ts index f8fe060d8..10daeaf2a 100644 --- a/src/app/components/admin/admin-dashboard/admin-dashboard.component.spec.ts +++ b/src/app/components/admin/admin-dashboard/admin-dashboard.component.spec.ts @@ -14,7 +14,7 @@ import { FirebaseApp } from '@angular/fire/app'; import { AppThemeService } from '../../../services/app.theme.service'; import { AppThemes } from '@sports-alliance/sports-lib'; import { BehaviorSubject } from 'rxjs'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; // Mock canvas for charts // Mock canvas for charts @@ -58,11 +58,17 @@ global.ResizeObserver = class ResizeObserver { disconnect() { } }; +// Mock requestAnimationFrame for ECharts resize scheduling +if (!(global as any).requestAnimationFrame) { + (global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0); +} + describe('AdminDashboardComponent', () => { let component: AdminDashboardComponent; let fixture: ComponentFixture; let adminServiceSpy: any; let mockLogger: any; + let mockEchartsService: any; const mockQueueStats = { pending: 10, @@ -80,14 +86,28 @@ describe('AdminDashboardComponent', () => { beforeEach(async () => { adminServiceSpy = { - getQueueStats: vi.fn().mockReturnValue(of(mockQueueStats)), - getFinancialStats: vi.fn().mockReturnValue(of(mockFinancialStats)), - }; + getQueueStats: vi.fn().mockReturnValue(of(mockQueueStats)), + getFinancialStats: vi.fn().mockReturnValue(of(mockFinancialStats)), + }; + + const chartMock = { + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + }; - mockLogger = { - error: vi.fn(), - log: vi.fn() - }; + mockEchartsService = { + init: vi.fn().mockResolvedValue(chartMock), + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn() + }; + + mockLogger = { + error: vi.fn(), + log: vi.fn() + }; await TestBed.configureTestingModule({ imports: [ @@ -103,7 +123,7 @@ describe('AdminDashboardComponent', () => { { provide: Auth, useValue: {} }, { provide: FirebaseApp, useValue: {} }, { provide: AppThemeService, useValue: { getAppTheme: () => new BehaviorSubject(AppThemes.Dark).asObservable() } }, - provideCharts(withDefaultRegisterables()) + { provide: EChartsLoaderService, useValue: mockEchartsService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/components/admin/admin-financials/admin-financials.component.scss b/src/app/components/admin/admin-financials/admin-financials.component.scss index 1a82f7779..e1bda5900 100644 --- a/src/app/components/admin/admin-financials/admin-financials.component.scss +++ b/src/app/components/admin/admin-financials/admin-financials.component.scss @@ -38,22 +38,6 @@ } } -// Card Styles - Copied from admin-dashboard.component.scss -//Ideally these should be in a shared SCSS file -.glass-card { - background: var(--mat-sys-surface, white); // Fallback - border-radius: 12px; - padding: 1.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - - :host-context(.dark-theme) & { - background: rgba(30, 30, 30, 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - } -} - .app-stat-card { display: flex; flex-direction: column; @@ -142,4 +126,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.html b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.html index c7b204719..b0d62a34f 100644 --- a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.html +++ b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.html @@ -98,10 +98,12 @@

Retry Distribution (Pending Items)

-
- - +
+
+
+ check_circle + No pending retries right now +
@@ -207,4 +209,4 @@

Provider Queue Status

- \ No newline at end of file + diff --git a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.scss b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.scss index 8a7569ebc..8c329e120 100644 --- a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.scss +++ b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.scss @@ -38,21 +38,6 @@ } } -// Card Styles - Shared -.glass-card { - background: var(--mat-sys-surface, white); // Fallback - border-radius: 12px; - padding: 1.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - - :host-context(.dark-theme) & { - background: rgba(30, 30, 30, 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - } -} - .app-stat-card { display: flex; flex-direction: column; @@ -150,20 +135,40 @@ .chart-wrapper { width: 100%; - min-height: 300px; + min-height: 320px; display: block; position: relative; - padding: 0 1.5rem 1.5rem; + padding: 0 1.5rem 1.75rem; box-sizing: border-box; @include bp.xsmall { padding: 0 1rem 1rem; - min-height: 250px; + min-height: 280px; } - canvas { - width: 100% !important; - height: 100% !important; + .echart-surface { + width: 100%; + height: 280px; + } + + .chart-empty-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #6b7280; + font-size: 0.95rem; + pointer-events: none; + + mat-icon { + color: #4caf50; + } + + :host-context(.dark-theme) & { + color: rgba(255, 255, 255, 0.65); + } } } @@ -336,4 +341,4 @@ td.mat-cell { opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.spec.ts b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.spec.ts index 58f00d4eb..945d07afd 100644 --- a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.spec.ts +++ b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.spec.ts @@ -6,21 +6,42 @@ import { of } from 'rxjs'; import { AppThemes } from '@sports-alliance/sports-lib'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { SimpleChange } from '@angular/core'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; describe('AdminQueueStatsComponent', () => { let component: AdminQueueStatsComponent; let fixture: ComponentFixture; let mockThemeService: any; + let mockEchartsService: any; + + if (!(global as any).requestAnimationFrame) { + (global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0); + } beforeEach(async () => { mockThemeService = { getAppTheme: vi.fn().mockReturnValue(of(AppThemes.Light)) }; + const chartMock = { + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + }; + + mockEchartsService = { + init: vi.fn().mockResolvedValue(chartMock), + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn() + }; + await TestBed.configureTestingModule({ imports: [AdminQueueStatsComponent], providers: [ - { provide: AppThemeService, useValue: mockThemeService } + { provide: AppThemeService, useValue: mockThemeService }, + { provide: EChartsLoaderService, useValue: mockEchartsService } ] }).compileComponents(); @@ -58,7 +79,44 @@ describe('AdminQueueStatsComponent', () => { }); describe('Chart Updates', () => { - it('should update chart data on input change', () => { + it('should initialize chart when retry container appears after async stats load', async () => { + component.loading = true; + component.stats = null; + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockEchartsService.init).not.toHaveBeenCalled(); + + const asyncStats: QueueStats = { + pending: 4, + succeeded: 20, + stuck: 1, + providers: [], + advanced: { + throughput: 11, + maxLagMs: 2000, + retryHistogram: { + '0-3': 2, + '4-7': 1, + '8-9': 0 + }, + topErrors: [] + }, + cloudTasks: { pending: 1, succeeded: 2, failed: 0 }, + dlq: { total: 0, byContext: [], byProvider: [] } + }; + + component.loading = false; + component.stats = asyncStats; + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockEchartsService.init).toHaveBeenCalledTimes(1); + expect(mockEchartsService.setOption).toHaveBeenCalled(); + }); + + it('should update chart data on input change', async () => { const mockStats: QueueStats = { pending: 10, succeeded: 100, @@ -78,13 +136,15 @@ describe('AdminQueueStatsComponent', () => { dlq: { total: 0, byContext: [], byProvider: [] } }; - // Direct assignment + OnChanges simulation component.stats = mockStats; - component.ngOnChanges({ - stats: new SimpleChange(null, mockStats, true) - }); + component.ngOnChanges({ stats: new SimpleChange(null, mockStats, true) }); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(component.barChartData.datasets[0].data).toEqual([5, 3, 2]); + expect(mockEchartsService.setOption).toHaveBeenCalled(); + const optionArg = mockEchartsService.setOption.mock.calls[0][1]; + expect(optionArg.series[0].data).toEqual([5, 3, 2]); }); }); }); diff --git a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.ts b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.ts index 4b6b84036..2cb992f87 100644 --- a/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.ts +++ b/src/app/components/admin/admin-queue-stats/admin-queue-stats.component.ts @@ -1,19 +1,18 @@ -import { Component, Input, OnChanges, SimpleChanges, OnInit, OnDestroy } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTableModule } from '@angular/material/table'; -import { BaseChartDirective } from 'ng2-charts'; -import { ChartConfiguration } from 'chart.js'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { QueueStats } from '../../../services/admin.service'; import { AppThemeService } from '../../../services/app.theme.service'; import { AppThemes } from '@sports-alliance/sports-lib'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import type { EChartsType } from 'echarts/core'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; @Component({ selector: 'app-admin-queue-stats', @@ -26,28 +25,39 @@ import { takeUntil } from 'rxjs/operators'; MatProgressSpinnerModule, MatButtonModule, MatTooltipModule, - MatTableModule, - BaseChartDirective - ], - providers: [provideCharts(withDefaultRegisterables())] + MatTableModule + ] }) -export class AdminQueueStatsComponent implements OnInit, OnChanges, OnDestroy { +export class AdminQueueStatsComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { @Input() stats: QueueStats | null = null; @Input() loading = false; + hasRetryData = false; - // Chart configuration - public barChartLegend = true; - public barChartPlugins = []; - public barChartData: ChartConfiguration<'bar'>['data'] = { - labels: ['0-3 Retries', '4-7 Retries', '8-9 Retries'], - datasets: [ - { data: [0, 0, 0], label: 'Pending Items' } - ] - }; - public barChartOptions: ChartConfiguration<'bar'>['options'] = { - responsive: true, - maintainAspectRatio: false - }; + @ViewChild('retryChart') + set retryChartRef(ref: ElementRef | undefined) { + this._retryChartRef = ref; + + if (!ref) { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + this.eChartsLoader.dispose(this.chart); + this.chart = null; + return; + } + + if (this.viewInitialized) { + void this.tryInitializeChartAndRender(); + } + } + + private chart: EChartsType | null = null; + private chartInitialization: Promise | null = null; + private viewInitialized = false; + private _retryChartRef: ElementRef | undefined; + private isDark = false; + private resizeObserver: ResizeObserver | null = null; // Theme constants private readonly CHART_TEXT_DARK = 'rgba(255, 255, 255, 0.8)'; @@ -57,71 +67,169 @@ export class AdminQueueStatsComponent implements OnInit, OnChanges, OnDestroy { private destroy$ = new Subject(); - constructor(private appThemeService: AppThemeService) { } + constructor( + private appThemeService: AppThemeService, + private eChartsLoader: EChartsLoaderService + ) { } ngOnInit(): void { this.appThemeService.getAppTheme().pipe(takeUntil(this.destroy$)).subscribe(theme => { - this.updateChartTheme(theme); + this.isDark = theme === AppThemes.Dark; + this.updateChartTheme(); }); } + async ngAfterViewInit(): Promise { + this.viewInitialized = true; + await this.tryInitializeChartAndRender(); + } + ngOnChanges(changes: SimpleChanges): void { - if (changes['stats'] && this.stats) { - this.updateChartData(); + if (changes['stats'] || changes['loading']) { + void this.tryInitializeChartAndRender(); } } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + this.eChartsLoader.dispose(this.chart); + this.chart = null; } - private updateChartData(): void { - if (this.stats?.advanced?.retryHistogram) { - this.barChartData = { - labels: ['0-3 Retries', '4-7 Retries', '8-9 Retries'], - datasets: [ - { - data: [ - this.stats.advanced.retryHistogram['0-3'], - this.stats.advanced.retryHistogram['4-7'], - this.stats.advanced.retryHistogram['8-9'] - ], - label: 'Pending Items', - backgroundColor: [ - 'rgba(75, 192, 192, 0.6)', // Greenish - 'rgba(255, 206, 86, 0.6)', // Yellowish - 'rgba(255, 99, 132, 0.6)' // Reddish - ] - } - ] - }; + private async tryInitializeChartAndRender(): Promise { + await this.initializeChart(); + this.updateChartData(); + } + + private async initializeChart(): Promise { + if (this.chart || this.chartInitialization || !this._retryChartRef?.nativeElement) { + return; + } + + const container = this._retryChartRef.nativeElement; + this.chartInitialization = (async () => { + try { + this.chart = await this.eChartsLoader.init(container); + this.setupResizeObserver(container); + } catch (error) { + console.error('[AdminQueueStatsComponent] Failed to initialize ECharts', error); + } finally { + this.chartInitialization = null; + } + })(); + + await this.chartInitialization; + } + + private setupResizeObserver(container: HTMLElement): void { + if (typeof ResizeObserver === 'undefined') { + return; + } + + this.resizeObserver = new ResizeObserver(() => { + this.scheduleResize(); + }); + this.resizeObserver.observe(container); + } + + private scheduleResize(): void { + if (!this.chart) return; + if (typeof requestAnimationFrame === 'undefined') { + this.eChartsLoader.resize(this.chart); + return; } + requestAnimationFrame(() => this.eChartsLoader.resize(this.chart!)); } - private updateChartTheme(theme: AppThemes): void { - const isDark = theme === AppThemes.Dark; - const textColor = isDark ? this.CHART_TEXT_DARK : this.CHART_TEXT_LIGHT; - const gridColor = isDark ? this.CHART_GRID_DARK : this.CHART_GRID_LIGHT; - - this.barChartOptions = { - ...this.barChartOptions, - scales: { - x: { - ticks: { color: textColor }, - grid: { color: gridColor } - }, - y: { - ticks: { color: textColor }, - grid: { color: gridColor } + private updateChartData(): void { + if (!this.chart || !this._retryChartRef?.nativeElement) { + return; + } + + const histogram = this.stats?.advanced?.retryHistogram; + const values = histogram + ? [ + histogram['0-3'] ?? 0, + histogram['4-7'] ?? 0, + histogram['8-9'] ?? 0 + ] + : [0, 0, 0]; + const maxValue = Math.max(...values); + this.hasRetryData = maxValue > 0; + + const textColor = this.isDark ? this.CHART_TEXT_DARK : this.CHART_TEXT_LIGHT; + const gridColor = this.isDark ? this.CHART_GRID_DARK : this.CHART_GRID_LIGHT; + + const option = { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter: (params: any) => { + const item = Array.isArray(params) ? params[0] : params; + return `${item?.axisValueLabel || item?.name}: ${item?.value ?? 0}`; } }, - plugins: { - legend: { - labels: { color: textColor } + grid: { left: 18, right: 18, bottom: 32, top: 16, containLabel: true }, + xAxis: { + type: 'category', + data: ['0-3 Retries', '4-7 Retries', '8-9 Retries'], + axisLabel: { color: textColor }, + axisLine: { lineStyle: { color: gridColor } }, + axisTick: { alignWithLabel: true } + }, + yAxis: { + type: 'value', + min: 0, + minInterval: 1, + max: maxValue === 0 ? 1 : undefined, + axisLabel: { color: textColor }, + splitLine: { lineStyle: { color: gridColor, width: 1.2 } } + }, + series: [ + { + type: 'bar', + name: 'Pending Items', + data: values, + barMaxWidth: 54, + barCategoryGap: '30%', + barMinHeight: 8, + itemStyle: { + color: (params: any) => { + const idx = params?.dataIndex ?? 0; + if (idx === 0) return '#4caf50'; + if (idx === 1) return '#ffb300'; + return '#f44336'; + }, + borderRadius: [6, 6, 0, 0], + shadowBlur: 6, + shadowColor: this.isDark ? 'rgba(0,0,0,0.35)' : 'rgba(0,0,0,0.18)' + }, + label: { + show: true, + position: 'top', + color: textColor, + fontWeight: 600, + fontSize: 12, + distance: 6 + } } - } + ] }; + + this.eChartsLoader.setOption(this.chart, option, { notMerge: true, lazyUpdate: true }); + this.scheduleResize(); + } + + private updateChartTheme(): void { + if (!this.chart) { + return; + } + this.updateChartData(); } formatDuration(ms: number): string { diff --git a/src/app/components/admin/admin-user-management/admin-user-management.component.html b/src/app/components/admin/admin-user-management/admin-user-management.component.html index afbf8d82f..a1ef23402 100644 --- a/src/app/components/admin/admin-user-management/admin-user-management.component.html +++ b/src/app/components/admin/admin-user-management/admin-user-management.component.html @@ -77,9 +77,8 @@

Auth Provider Breakdown

-
- - +
+
@@ -258,4 +257,4 @@

- \ No newline at end of file + diff --git a/src/app/components/admin/admin-user-management/admin-user-management.component.scss b/src/app/components/admin/admin-user-management/admin-user-management.component.scss index 6a55fe4b5..f7da7c4db 100644 --- a/src/app/components/admin/admin-user-management/admin-user-management.component.scss +++ b/src/app/components/admin/admin-user-management/admin-user-management.component.scss @@ -79,20 +79,6 @@ } } -.glass-card { - background: var(--mat-sys-surface, white); - border-radius: 12px; - padding: 1.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - - :host-context(.dark-theme) & { - background: rgba(30, 30, 30, 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - } -} - .app-stat-card { display: flex; flex-direction: column; @@ -178,9 +164,9 @@ position: relative; box-sizing: border-box; - canvas { - width: 100% !important; - height: 100% !important; + .echart-surface { + width: 100%; + height: 240px; } } @@ -362,4 +348,4 @@ td.mat-cell { opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/app/components/admin/admin-user-management/admin-user-management.component.spec.ts b/src/app/components/admin/admin-user-management/admin-user-management.component.spec.ts index f059b4311..3298a9bd3 100644 --- a/src/app/components/admin/admin-user-management/admin-user-management.component.spec.ts +++ b/src/app/components/admin/admin-user-management/admin-user-management.component.spec.ts @@ -17,10 +17,10 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { ActivatedRoute, Router } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; // Mock canvas for charts Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { @@ -69,6 +69,11 @@ global.ResizeObserver = class ResizeObserver { disconnect() { } }; +// Mock requestAnimationFrame for ECharts usage +if (!(global as any).requestAnimationFrame) { + (global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0); +} + describe('AdminUserManagementComponent', () => { let component: AdminUserManagementComponent; let fixture: ComponentFixture; @@ -79,6 +84,7 @@ describe('AdminUserManagementComponent', () => { let appThemeServiceMock: any; let themeSubject: BehaviorSubject; let mockLogger: any; + let mockEchartsService: any; const mockUsers: AdminUser[] = [ { @@ -139,6 +145,20 @@ describe('AdminUserManagementComponent', () => { log: vi.fn() }; + const chartMock = { + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + }; + + mockEchartsService = { + init: vi.fn().mockResolvedValue(chartMock), + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn() + }; + await TestBed.configureTestingModule({ imports: [ AdminUserManagementComponent, @@ -160,7 +180,7 @@ describe('AdminUserManagementComponent', () => { { provide: Router, useValue: routerSpy }, { provide: MatDialog, useValue: matDialogSpy }, { provide: MatSnackBar, useValue: { open: vi.fn() } }, - provideCharts(withDefaultRegisterables()), + { provide: EChartsLoaderService, useValue: mockEchartsService }, { provide: ActivatedRoute, useValue: { diff --git a/src/app/components/admin/admin-user-management/admin-user-management.component.ts b/src/app/components/admin/admin-user-management/admin-user-management.component.ts index 1c2483a13..fb6592f5e 100644 --- a/src/app/components/admin/admin-user-management/admin-user-management.component.ts +++ b/src/app/components/admin/admin-user-management/admin-user-management.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; @@ -13,9 +13,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { BaseChartDirective } from 'ng2-charts'; -import { ChartConfiguration, ChartOptions } from 'chart.js'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; @@ -26,6 +23,8 @@ import { LoggerService } from '../../../services/logger.service'; import { ConfirmationDialogComponent } from '../../confirmation-dialog/confirmation-dialog.component'; import { AdminResolverData } from '../../../resolvers/admin.resolver'; import { AppThemes } from '@sports-alliance/sports-lib'; +import type { EChartsType } from 'echarts/core'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; export interface UserStats { total: number; @@ -35,6 +34,8 @@ export interface UserStats { providers?: Record; } +type ChartOption = Parameters[0]; + @Component({ selector: 'app-admin-user-management', templateUrl: './admin-user-management.component.html', @@ -55,12 +56,11 @@ export interface UserStats { MatSelectModule, MatDialogModule, MatSnackBarModule, - BaseChartDirective - ], - providers: [provideCharts(withDefaultRegisterables())] + ] }) -export class AdminUserManagementComponent implements OnInit, OnDestroy { +export class AdminUserManagementComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild(MatSort) sort!: MatSort; + @ViewChild('authChart', { static: true }) authChartRef!: ElementRef; // Injected services private adminService = inject(AdminService); @@ -71,6 +71,7 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); private logger = inject(LoggerService); + private eChartsLoader = inject(EChartsLoaderService); // Data state users: AdminUser[] = []; @@ -96,23 +97,15 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { private searchSubject = new Subject(); private destroy$ = new Subject(); - // Chart configuration - public authPieChartData: ChartConfiguration<'pie'>['data'] = { - labels: [], - datasets: [{ data: [], backgroundColor: [] }] - }; - public authPieChartOptions: ChartOptions<'pie'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { position: 'right', labels: { padding: 20 } } - } - }; + private chart: EChartsType | null = null; + private isDark = false; + private resizeObserver: ResizeObserver | null = null; + private providerData: Record | null = null; private readonly CHART_TEXT_DARK = 'rgba(255, 255, 255, 0.8)'; private readonly CHART_TEXT_LIGHT = 'rgba(0, 0, 0, 0.8)'; - ngOnInit(): void { + async ngOnInit(): Promise { // Handle search debounce this.searchSubject.pipe( debounceTime(300), @@ -126,7 +119,8 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { // Handle theme changes for chart this.appThemeService.getAppTheme().pipe(takeUntil(this.destroy$)).subscribe(theme => { - this.updateChartTheme(theme); + this.isDark = theme === AppThemes.Dark; + this.updateChartTheme(); }); // Use resolved data if available @@ -151,9 +145,21 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { } } + async ngAfterViewInit(): Promise { + await this.initializeChart(); + this.updateChartTheme(); + this.updateAuthChart(this.providerData ?? {}); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + this.eChartsLoader.dispose(this.chart); + this.chart = null; } fetchUsers(): void { @@ -184,45 +190,8 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { } private updateAuthChart(providers: Record): void { - const providerLabels: Record = { - 'google.com': 'Google', - 'password': 'Email/Password', - 'apple.com': 'Apple', - 'facebook.com': 'Facebook' - }; - const providerColors: Record = { - 'google.com': '#4285F4', - 'password': '#34A853', - 'apple.com': '#555555', - 'facebook.com': '#1877F2' - }; - - this.authPieChartData = { - labels: Object.keys(providers).map(p => providerLabels[p] || p), - datasets: [{ - data: Object.values(providers), - backgroundColor: Object.keys(providers).map(p => providerColors[p] || '#9E9E9E') - }] - }; - } - - private updateChartTheme(theme: AppThemes): void { - const isDark = theme === AppThemes.Dark; - const textColor = isDark ? this.CHART_TEXT_DARK : this.CHART_TEXT_LIGHT; - - this.authPieChartOptions = { - ...this.authPieChartOptions, - plugins: { - ...this.authPieChartOptions!.plugins, - legend: { - ...this.authPieChartOptions!.plugins!.legend, - labels: { - ...((this.authPieChartOptions!.plugins!.legend as any)?.labels || {}), - color: textColor - } - } - } - }; + this.providerData = providers; + this.renderAuthChart(); } onPageChange(event: PageEvent): void { @@ -340,4 +309,161 @@ export class AdminUserManagementComponent implements OnInit, OnDestroy { default: return ''; } } + + private async initializeChart(): Promise { + if (!this.authChartRef?.nativeElement) { + return; + } + try { + this.chart = await this.eChartsLoader.init(this.authChartRef.nativeElement); + this.setupResizeObserver(); + } catch (error) { + this.logger.error('[AdminUserManagementComponent] Failed to initialize ECharts', error); + } + } + + private setupResizeObserver(): void { + if (typeof ResizeObserver === 'undefined' || !this.authChartRef?.nativeElement) { + return; + } + this.resizeObserver = new ResizeObserver(() => this.scheduleResize()); + this.resizeObserver.observe(this.authChartRef.nativeElement); + } + + private scheduleResize(): void { + if (!this.chart) return; + if (typeof requestAnimationFrame === 'undefined') { + this.eChartsLoader.resize(this.chart); + return; + } + requestAnimationFrame(() => this.eChartsLoader.resize(this.chart!)); + } + + private renderAuthChart(): void { + if (!this.chart || !this.providerData || Object.keys(this.providerData).length === 0) { + return; + } + + const option = this.buildAuthChartOption(this.providerData); + this.eChartsLoader.setOption(this.chart, option, { notMerge: true, lazyUpdate: true }); + this.scheduleResize(); + } + + private buildAuthChartOption(providers: Record): ChartOption { + const providerLabels: Record = { + 'google.com': 'Google', + 'password': 'Email/Password', + 'apple.com': 'Apple', + 'facebook.com': 'Facebook' + }; + const providerColors: Record = { + 'google.com': '#4285F4', + 'password': '#34A853', + 'apple.com': '#555555', + 'facebook.com': '#1877F2' + }; + + const entries = Object.entries(providers); + const total = entries.reduce((sum, [, value]) => sum + value, 0); + const sorted = [...entries].sort((a, b) => b[1] - a[1]); + const topProvider = sorted[0]?.[0]; + + const textColor = this.isDark ? this.CHART_TEXT_DARK : this.CHART_TEXT_LIGHT; + const borderColor = this.isDark ? 'rgba(255,255,255,0.05)' : '#ffffff'; + + const seriesData = entries.map(([key, value]) => ({ + name: providerLabels[key] || key, + value, + itemStyle: { color: providerColors[key] || '#9E9E9E' } + })); + + const centerText = total > 0 ? `${total}` : '0'; + const centerSubtitle = topProvider ? `${providerLabels[topProvider] || topProvider}` : 'No data'; + + const option: ChartOption = { + tooltip: { + trigger: 'item', + formatter: '{b}: {c} ({d}%)' + }, + legend: { + orient: 'vertical', + right: 10, + top: 'center', + textStyle: { color: textColor } + }, + series: [ + { + name: 'Auth Provider Breakdown', + type: 'pie', + radius: ['55%', '72%'], + center: ['38%', '50%'], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + itemStyle: { + borderColor, + borderWidth: 2 + }, + data: seriesData + } + ], + graphic: [ + { + type: 'group', + left: '38%', + top: 'center', + bounding: 'raw', + children: [ + { + type: 'text', + style: { + text: centerText, + fontSize: 24, + fontWeight: 700, + fill: textColor, + textAlign: 'center' + }, + left: 'center', + top: -12 + }, + { + type: 'text', + style: { + text: 'accounts', + fontSize: 12, + fontWeight: 400, + fill: textColor, + opacity: 0.75, + textAlign: 'center' + }, + left: 'center', + top: 10 + }, + { + type: 'text', + style: { + text: centerSubtitle, + fontSize: 12, + fontWeight: 500, + fill: textColor, + opacity: 0.9, + textAlign: 'center' + }, + left: 'center', + top: 28 + } + ] + } + ] + }; + + return option; + } + + private updateChartTheme(): void { + if (!this.chart) { + return; + } + this.renderAuthChart(); + } } diff --git a/src/app/components/benchmark/benchmark-bottom-sheet.component.css b/src/app/components/benchmark/benchmark-bottom-sheet.component.scss similarity index 89% rename from src/app/components/benchmark/benchmark-bottom-sheet.component.css rename to src/app/components/benchmark/benchmark-bottom-sheet.component.scss index b56d25956..55903271b 100644 --- a/src/app/components/benchmark/benchmark-bottom-sheet.component.css +++ b/src/app/components/benchmark/benchmark-bottom-sheet.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + :host { display: block; height: 100%; @@ -58,7 +60,12 @@ pointer-events: none; } -.benchmark-watermark .watermark-row { +.benchmark-watermark .watermark-brand-line { + font-size: 0.65rem; + letter-spacing: 0.04em; +} + +.benchmark-watermark .watermark-app-line { display: inline-flex; align-items: center; gap: 6px; @@ -75,12 +82,7 @@ text-transform: uppercase; } -.benchmark-watermark .watermark-url { - font-size: 0.6rem; - letter-spacing: 0.04em; -} - -@media (max-width: 600px) { +@include bp.xsmall { .bottom-sheet-content { padding: 0 0.75rem 1rem 0.75rem; } diff --git a/src/app/components/benchmark/benchmark-bottom-sheet.component.spec.ts b/src/app/components/benchmark/benchmark-bottom-sheet.component.spec.ts index e63ff889f..cdb882cc6 100644 --- a/src/app/components/benchmark/benchmark-bottom-sheet.component.spec.ts +++ b/src/app/components/benchmark/benchmark-bottom-sheet.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_BOTTOM_SHEET_DATA, MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { BenchmarkBottomSheetComponent } from './benchmark-bottom-sheet.component'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; @@ -12,6 +12,8 @@ import { BenchmarkResult } from '../../../../functions/src/shared/app-event.inte import { Component, Input } from '@angular/core'; import { EventInterface, UserSummariesSettingsInterface, UserUnitSettingsInterface } from '@sports-alliance/sports-lib'; import { BottomSheetHeaderComponent } from '../shared/bottom-sheet-header/bottom-sheet-header.component'; +import { AppShareService } from '../../services/app.share.service'; +import { AppEventColorService } from '../../services/color/app.event.color.service'; // Mock the BenchmarkReportComponent since we're testing the sheet, not the report @Component({ @@ -32,6 +34,8 @@ describe('BenchmarkBottomSheetComponent', () => { let component: BenchmarkBottomSheetComponent; let fixture: ComponentFixture; let mockBottomSheetRef: { dismiss: ReturnType }; + let shareServiceMock: { shareBenchmarkAsImage: ReturnType }; + let originalMatchMedia: typeof window.matchMedia | undefined; const mockResult: BenchmarkResult = { referenceId: 'ref-id', @@ -53,6 +57,23 @@ describe('BenchmarkBottomSheetComponent', () => { beforeEach(async () => { mockBottomSheetRef = { dismiss: vi.fn() }; + shareServiceMock = { + shareBenchmarkAsImage: vi.fn().mockResolvedValue('data:image/png;base64,QUJD'), + }; + originalMatchMedia = window.matchMedia; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + media: '(max-width: 600px)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); await TestBed.configureTestingModule({ declarations: [ @@ -71,6 +92,8 @@ describe('BenchmarkBottomSheetComponent', () => { { provide: MatBottomSheetRef, useValue: mockBottomSheetRef }, { provide: MAT_BOTTOM_SHEET_DATA, useValue: { result: mockResult, event: { getActivities: () => [] } } }, { provide: MatSnackBar, useValue: { open: vi.fn() } }, + { provide: AppShareService, useValue: shareServiceMock }, + { provide: AppEventColorService, useValue: { getActivityColor: vi.fn().mockReturnValue('#000000') } }, ], }).compileComponents(); @@ -79,6 +102,15 @@ describe('BenchmarkBottomSheetComponent', () => { fixture.detectChanges(); }); + afterEach(() => { + if (originalMatchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: originalMatchMedia, + }); + } + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -97,4 +129,42 @@ describe('BenchmarkBottomSheetComponent', () => { expect(component.data.result.referenceName).toBe('Garmin Forerunner 265'); expect(component.data.result.testName).toBe('COROS PACE 3'); }); + + it('should use custom brandText plus Quantified Self in export watermark', async () => { + component.data.brandText = ' My Brand '; + component.shareFrame = { + nativeElement: document.createElement('div'), + } as any; + + await (component as any).buildSharePayload(); + + expect(shareServiceMock.shareBenchmarkAsImage).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ + watermark: expect.objectContaining({ + brand: 'My Brand', + logoUrl: expect.stringContaining('assets/logos/app/logo-100x100.png'), + }), + }), + ); + }); + + it('should fallback to Quantified Self when brandText is empty', async () => { + component.data.brandText = ' '; + component.shareFrame = { + nativeElement: document.createElement('div'), + } as any; + + await (component as any).buildSharePayload(); + + expect(shareServiceMock.shareBenchmarkAsImage).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ + watermark: expect.objectContaining({ + brand: '', + logoUrl: expect.stringContaining('assets/logos/app/logo-100x100.png'), + }), + }), + ); + }); }); diff --git a/src/app/components/benchmark/benchmark-bottom-sheet.component.ts b/src/app/components/benchmark/benchmark-bottom-sheet.component.ts index 241398336..cbdc4041b 100644 --- a/src/app/components/benchmark/benchmark-bottom-sheet.component.ts +++ b/src/app/components/benchmark/benchmark-bottom-sheet.component.ts @@ -4,6 +4,7 @@ import { MAT_BOTTOM_SHEET_DATA, MatBottomSheetRef } from '@angular/material/bott import { MatSnackBar } from '@angular/material/snack-bar'; import { BenchmarkResult } from '../../../../functions/src/shared/app-event.interface'; import { AppEventColorService } from '../../services/color/app.event.color.service'; +import { AppBreakpoints } from '../../constants/breakpoints'; import { EventInterface, UserSummariesSettingsInterface, UserUnitSettingsInterface } from '@sports-alliance/sports-lib'; import { AppShareService } from '../../services/app.share.service'; import { environment } from '../../../environments/environment'; @@ -19,7 +20,7 @@ type NativeShareStatus = 'shared' | 'unsupported' | 'cancelled' | 'failed'; - + `, - styleUrls: ['./benchmark-selection-dialog.component.css'], + styleUrls: ['./benchmark-selection-dialog.component.scss'], standalone: false }) export class BenchmarkSelectionDialogComponent { diff --git a/src/app/components/charts/columns/charts.columns.component.html b/src/app/components/charts/columns/charts.columns.component.html index 02094cb08..db51432b9 100644 --- a/src/app/components/charts/columns/charts.columns.component.html +++ b/src/app/components/charts/columns/charts.columns.component.html @@ -1,2 +1,5 @@ -
- + +
+
diff --git a/src/app/components/charts/columns/charts.columns.component.ts b/src/app/components/charts/columns/charts.columns.component.ts index 53e6ae6c8..f750152ef 100644 --- a/src/app/components/charts/columns/charts.columns.component.ts +++ b/src/app/components/charts/columns/charts.columns.component.ts @@ -24,6 +24,7 @@ import { LoggerService } from '../../../services/logger.service'; import { AppColors } from '../../../services/color/app.colors'; import { ActivityTypes } from '@sports-alliance/sports-lib'; import { ChartDataCategoryTypes, TimeIntervals } from '@sports-alliance/sports-lib'; +import { normalizeUnitDerivedTypeLabel } from '../../../helpers/stat-label.helper'; @Component({ @@ -67,7 +68,11 @@ export class ChartsColumnsComponent extends DashboardChartAbstractDirective impl chartTitle.adapter.add('text', (text, target, key) => { const data = target.parent.parent.parent.parent['data']; const value = this.getAggregateData(data, this.chartDataValueType); - return `[font-size: 1.4em]${value.getDisplayType()}[/] [bold font-size: 1.3em]${value.getDisplayValue()}${value.getDisplayUnit()}[/] (${this.chartDataValueType}${this.chartDataCategoryType === ChartDataCategoryTypes.DateType ? ` @ ${TimeIntervals[this.chartDataTimeInterval]}` : ``})`; + if (!value) { + return `[font-size: 1.4em]${this.chartDataValueType || 'Value'}[/] [bold font-size: 1.3em]--[/]`; + } + const normalizedLabel = normalizeUnitDerivedTypeLabel(value.getType(), value.getDisplayType()); + return `[font-size: 1.4em]${normalizedLabel}[/] [bold font-size: 1.3em]${value.getDisplayValue()}${value.getDisplayUnit()}[/] (${this.chartDataValueType}${this.chartDataCategoryType === ChartDataCategoryTypes.DateType ? ` @ ${TimeIntervals[this.chartDataTimeInterval]}` : ``})`; }); chartTitle.marginTop = core.percent(20); const categoryAxis = this.vertical ? chart.xAxes.push(this.getCategoryAxis(this.chartDataCategoryType, this.chartDataTimeInterval, charts)) : chart.yAxes.push(this.getCategoryAxis(this.chartDataCategoryType, this.chartDataTimeInterval, charts)); @@ -99,7 +104,10 @@ export class ChartsColumnsComponent extends DashboardChartAbstractDirective impl valueAxis.numberFormatter.numberFormat = `#`; // valueAxis.numberFormatter.numberFormat = `#${DynamicDataLoader.getDataClassFromDataType(this.chartDataType).unit}`; valueAxis.renderer.labels.template.adapter.add('text', (text, target) => { - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, Number(text)); + const data = this.getDataInstanceOrNull(text); + if (!data) { + return ''; + } return `[bold font-size: 1.0em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` }); valueAxis.renderer.labels.template.adapter.add('dx', (text, target) => { @@ -125,7 +133,10 @@ export class ChartsColumnsComponent extends DashboardChartAbstractDirective impl if (!target.dataItem || !target.dataItem.dataContext) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, target.dataItem.dataContext[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `${this.vertical ? `{dateX}{categoryX}` : '{dateY}{categoryY}'}\n[bold]${this.chartDataValueType}: ${data.getDisplayValue()}${data.getDisplayUnit()}[/b]\n${target.dataItem.dataContext['count'] ? `[bold]${target.dataItem.dataContext['count']}[/b] Activities` : ``}` }); @@ -172,7 +183,10 @@ export class ChartsColumnsComponent extends DashboardChartAbstractDirective impl if (!target.dataItem || !target.dataItem.dataContext) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, Number(target.dataItem.dataContext[this.chartDataValueType])); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `[bold font-size: 1.1em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` }); categoryLabel.label.background.fillOpacity = 1; diff --git a/src/app/components/charts/dashboard-chart-abstract-component.directive.spec.ts b/src/app/components/charts/dashboard-chart-abstract-component.directive.spec.ts new file mode 100644 index 000000000..1c1d8cbc2 --- /dev/null +++ b/src/app/components/charts/dashboard-chart-abstract-component.directive.spec.ts @@ -0,0 +1,105 @@ +import { ChangeDetectorRef, NgZone } from '@angular/core'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DashboardChartAbstractDirective } from './dashboard-chart-abstract-component.directive'; +import { ChartDataValueTypes, DataDistance, DynamicDataLoader } from '@sports-alliance/sports-lib'; +import { AmChartsService } from '../../services/am-charts.service'; +import { LoggerService } from '../../services/logger.service'; + +class TestDashboardChartDirective extends DashboardChartAbstractDirective { + constructor( + private readonly loggerMock: Pick + ) { + super( + new NgZone({ enableLongStackTrace: false }), + {} as ChangeDetectorRef, + {} as AmChartsService, + loggerMock as LoggerService + ); + } + + protected async createChart(): Promise { + return {} as any; + } + + public setDataType(dataType?: string): void { + this.chartDataType = dataType; + } + + public getDataInstanceForTest(value: unknown): any { + return this.getDataInstanceOrNull(value); + } + + public getAggregateForTest(data: any[], valueType: ChartDataValueTypes): any { + return this.getAggregateData(data, valueType); + } +} + +describe('DashboardChartAbstractDirective', () => { + let loggerMock: { warn: ReturnType }; + let directive: TestDashboardChartDirective; + + beforeEach(() => { + loggerMock = { + warn: vi.fn(), + }; + directive = new TestDashboardChartDirective(loggerMock); + directive.setDataType(DataDistance.type); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null without calling loader for non numeric values', () => { + const loaderSpy = vi.spyOn(DynamicDataLoader, 'getDataInstanceFromDataType'); + + expect(directive.getDataInstanceForTest(undefined)).toBeNull(); + expect(directive.getDataInstanceForTest(null)).toBeNull(); + expect(directive.getDataInstanceForTest('abc')).toBeNull(); + expect(loaderSpy).not.toHaveBeenCalled(); + }); + + it('should return null and warn when data loader throws', () => { + vi.spyOn(DynamicDataLoader, 'getDataInstanceFromDataType').mockImplementation(() => { + throw new Error('loader-failed'); + }); + + const result = directive.getDataInstanceForTest(42); + + expect(result).toBeNull(); + expect(loggerMock.warn).toHaveBeenCalled(); + }); + + it('should aggregate average values and return formatted data instance', () => { + vi.spyOn(DynamicDataLoader, 'getDataInstanceFromDataType').mockImplementation((_type: string, value: number) => { + return { + getDisplayValue: () => String(value), + getDisplayUnit: () => 'km', + getType: () => DataDistance.type, + getDisplayType: () => 'Distance', + } as any; + }); + + const result = directive.getAggregateForTest([ + { [ChartDataValueTypes.Average]: 10 }, + { [ChartDataValueTypes.Average]: 20 }, + { [ChartDataValueTypes.Average]: 30 }, + ], ChartDataValueTypes.Average); + + expect(result).toBeTruthy(); + expect(result.getDisplayValue()).toBe('20'); + expect(result.getDisplayUnit()).toBe('km'); + }); + + it('should return null when aggregate data has no finite values', () => { + const loaderSpy = vi.spyOn(DynamicDataLoader, 'getDataInstanceFromDataType'); + + const result = directive.getAggregateForTest([ + { [ChartDataValueTypes.Total]: undefined }, + { [ChartDataValueTypes.Total]: 'NaN' }, + ], ChartDataValueTypes.Total); + + expect(result).toBeNull(); + expect(loaderSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/charts/dashboard-chart-abstract-component.directive.ts b/src/app/components/charts/dashboard-chart-abstract-component.directive.ts index fb76886d7..ac005afd7 100644 --- a/src/app/components/charts/dashboard-chart-abstract-component.directive.ts +++ b/src/app/components/charts/dashboard-chart-abstract-component.directive.ts @@ -141,30 +141,52 @@ export abstract class DashboardChartAbstractDirective extends ChartAbstractDirec } } - protected getAggregateData(data: any[], chartDataValueType: ChartDataValueTypes): DataInterface { + protected getDataInstanceOrNull(value: unknown): DataInterface | null { + if (!this.chartDataType) { + return null; + } + if (value === null || value === undefined || value === '') { + return null; + } + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) { + return null; + } + try { + return DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, numericValue) || null; + } catch (error) { + this.logger.warn('[DashboardChartAbstractDirective] Failed to create chart data instance', { + chartDataType: this.chartDataType, + numericValue, + error + }); + return null; + } + } + + protected getAggregateData(data: any[], chartDataValueType: ChartDataValueTypes): DataInterface | null { + if (!Array.isArray(data) || !data.length || !chartDataValueType) { + return null; + } + const numericValues = data + .map(dataItem => Number(dataItem?.[chartDataValueType])) + .filter(value => Number.isFinite(value)); + + if (!numericValues.length) { + return null; + } + switch (chartDataValueType) { case ChartDataValueTypes.Average: - let count = 0; - return DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, data.reduce((sum, dataItem) => { - count++; - sum += dataItem[chartDataValueType]; - return sum; - }, 0) / count); + return this.getDataInstanceOrNull(numericValues.reduce((sum, value) => sum + value, 0) / numericValues.length); case ChartDataValueTypes.Maximum: - return DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, data.reduce((min, dataItem) => { - min = min <= dataItem[chartDataValueType] ? dataItem[chartDataValueType] : min; - return min; - }, -Infinity)); + return this.getDataInstanceOrNull(Math.max(...numericValues)); case ChartDataValueTypes.Minimum: - return DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, data.reduce((min, dataItem) => { - min = min > dataItem[chartDataValueType] ? dataItem[chartDataValueType] : min; - return min; - }, Infinity)); + return this.getDataInstanceOrNull(Math.min(...numericValues)); case ChartDataValueTypes.Total: - return DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, data.reduce((sum, dataItem) => { - sum += dataItem[chartDataValueType]; - return sum; - }, 0)); + return this.getDataInstanceOrNull(numericValues.reduce((sum, value) => sum + value, 0)); + default: + return null; } } diff --git a/src/app/components/charts/intensity-zones/charts.intensity-zones.component.html b/src/app/components/charts/intensity-zones/charts.intensity-zones.component.html index 02094cb08..42159888a 100644 --- a/src/app/components/charts/intensity-zones/charts.intensity-zones.component.html +++ b/src/app/components/charts/intensity-zones/charts.intensity-zones.component.html @@ -1,2 +1,5 @@ -
- + +
+
diff --git a/src/app/components/charts/pie/charts.pie.component.html b/src/app/components/charts/pie/charts.pie.component.html index 02094cb08..8c5a72d7b 100644 --- a/src/app/components/charts/pie/charts.pie.component.html +++ b/src/app/components/charts/pie/charts.pie.component.html @@ -1,2 +1,5 @@ -
- + +
+
diff --git a/src/app/components/charts/pie/charts.pie.component.ts b/src/app/components/charts/pie/charts.pie.component.ts index 360220d20..b305be749 100644 --- a/src/app/components/charts/pie/charts.pie.component.ts +++ b/src/app/components/charts/pie/charts.pie.component.ts @@ -14,7 +14,6 @@ import type * as am4core from '@amcharts/amcharts4/core'; import type * as am4charts from '@amcharts/amcharts4/charts'; import type * as am4plugins_sliceGrouper from '@amcharts/amcharts4/plugins/sliceGrouper'; -import { DynamicDataLoader } from '@sports-alliance/sports-lib'; import { ChartDataCategoryTypes, ChartDataValueTypes @@ -23,6 +22,7 @@ import { DashboardChartAbstractDirective } from '../dashboard-chart-abstract-com import { AppEventColorService } from '../../../services/color/app.event.color.service'; import { ActivityTypes } from '@sports-alliance/sports-lib'; import { LoggerService } from '../../../services/logger.service'; +import { normalizeUnitDerivedTypeLabel } from '../../../helpers/stat-label.helper'; @Component({ @@ -68,7 +68,10 @@ export class ChartsPieComponent extends DashboardChartAbstractDirective implemen if (!target.dataItem || !target.dataItem.values || !target.dataItem.dataContext) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, target.dataItem.dataContext[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `{category${this.chartDataCategoryType === ChartDataCategoryTypes.ActivityType ? `` : `.formatDate("${this.getChartDateFormat(this.chartDataTimeInterval)}")`}}\n${target.dataItem.values.value.percent.toFixed(1)}%\n[bold]${data.getDisplayValue()}${data.getDisplayUnit()}[/b]\n${target.dataItem.dataContext['count'] ? `${target.dataItem.dataContext['count']} Activities` : ``}` }); @@ -84,7 +87,10 @@ export class ChartsPieComponent extends DashboardChartAbstractDirective implemen return ''; } try { - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, target.dataItem.dataContext[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `[font-size: 1.1em]${this.chartDataCategoryType === ChartDataCategoryTypes.ActivityType ? target.dataItem.dataContext.type.slice(0, 40) : `{category.formatDate("${this.getChartDateFormat(this.chartDataTimeInterval)}")}`}[/]\n[bold]${data.getDisplayValue()}${data.getDisplayUnit()}[/b]` // return `[bold font-size: 1.2em]{value.percent.formatNumber('#.')}%[/] [font-size: 1.1em]${this.chartDataCategoryType === ChartDataCategoryTypes.ActivityType ? target.dataItem.dataContext.type.slice(0, 40) : `{category.formatDate('${this.getChartDateFormat(this.chartDataDateRange)}')}` || 'other'}[/]\n[bold]${data.getDisplayValue()}${data.getDisplayUnit()}[/b]` } catch (e) { @@ -112,8 +118,13 @@ export class ChartsPieComponent extends DashboardChartAbstractDirective implemen label.text = `{values.value.average.formatNumber('#')}`; } label.adapter.add('textOutput', (text, target, key) => { - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, Number(text)); - return `[font-size: 1.2em]${data.getDisplayType()}[/] + const data = this.getDataInstanceOrNull(text); + if (!data) { + return `[font-size: 1.2em]${this.chartDataValueType || 'Value'}[/] + [font-size: 1.3em]--[/]`; + } + const normalizedLabel = normalizeUnitDerivedTypeLabel(data.getType(), data.getDisplayType()); + return `[font-size: 1.2em]${normalizedLabel}[/] [font-size: 1.3em]${data.getDisplayValue()}${data.getDisplayUnit()}[/] [font-size: 0.9em]${this.chartDataValueType}[/]` }); diff --git a/src/app/components/charts/timeline/charts.timeline.component.html b/src/app/components/charts/timeline/charts.timeline.component.html index 02094cb08..4b1c5c1bc 100644 --- a/src/app/components/charts/timeline/charts.timeline.component.html +++ b/src/app/components/charts/timeline/charts.timeline.component.html @@ -1,2 +1,5 @@ -
- + +
+
diff --git a/src/app/components/charts/timeline/charts.timeline.component.ts b/src/app/components/charts/timeline/charts.timeline.component.ts index 44f1a7e9a..d3fd55034 100644 --- a/src/app/components/charts/timeline/charts.timeline.component.ts +++ b/src/app/components/charts/timeline/charts.timeline.component.ts @@ -18,14 +18,13 @@ import type * as am4core from '@amcharts/amcharts4/core'; import type * as am4charts from '@amcharts/amcharts4/charts'; import type * as am4plugins_timeline from '@amcharts/amcharts4/plugins/timeline'; - -import { DynamicDataLoader } from '@sports-alliance/sports-lib'; import { DashboardChartAbstractDirective } from '../dashboard-chart-abstract-component.directive'; import { SummariesChartDataInterface } from '../../summaries/summaries.component'; import { ChartHelper } from '../../event/chart/chart-helper'; import { AppEventColorService } from '../../../services/color/app.event.color.service'; import { ActivityTypes } from '@sports-alliance/sports-lib'; import { LoggerService } from '../../../services/logger.service'; +import { normalizeUnitDerivedTypeLabel } from '../../../helpers/stat-label.helper'; @Component({ selector: 'app-timeline-chart', @@ -89,7 +88,10 @@ export class ChartsTimelineComponent extends DashboardChartAbstractDirective imp if (!chartDataItem) { return `[bold font-size: 0.8em]${text}[/]`; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, chartDataItem[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(chartDataItem[this.chartDataValueType]); + if (!data) { + return `[bold font-size: 0.8em]${text}[/]`; + } return `[bold font-size: 0.8em]${text} ${data.getDisplayValue()} ${data.getDisplayUnit()}[/]`; }); @@ -138,7 +140,10 @@ export class ChartsTimelineComponent extends DashboardChartAbstractDirective imp if (!target.dataItem || !target.dataItem.dataContext) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, target.dataItem.dataContext[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `${'{dateY}{categoryY}'} ${target.dataItem.dataContext['count'] ? `(x${target.dataItem.dataContext['count']})` : ``} [bold]${data.getDisplayValue()}${data.getDisplayUnit()}[/b] (${this.chartDataValueType})` }); @@ -150,8 +155,13 @@ export class ChartsTimelineComponent extends DashboardChartAbstractDirective imp label.verticalCenter = 'middle'; label.adapter.add('text', (text, target, key) => { const data = this.getAggregateData((target.parent).chart.data, this.chartDataValueType); + if (!data) { + return `[font-size: 1.3em]${this.chartDataValueType || 'Value'}[/] + [font-size: 1.4em]--[/]`; + } // return `[font-size: 1.3em]${value.getDisplayType()}[/] [bold font-size: 1.4em]${value.getDisplayValue()}${value.getDisplayUnit()}[/] (${this.chartDataValueType} )`; - return `[font-size: 1.3em]${data.getDisplayType()}[/] + const normalizedLabel = normalizeUnitDerivedTypeLabel(data.getType(), data.getDisplayType()); + return `[font-size: 1.3em]${normalizedLabel}[/] [font-size: 1.4em]${data.getDisplayValue()}${data.getDisplayUnit()}[/] [font-size: 1.0em]${this.chartDataValueType}[/]` }); diff --git a/src/app/components/charts/xy/charts.xy.component.html b/src/app/components/charts/xy/charts.xy.component.html index 02094cb08..4d6bdac9d 100644 --- a/src/app/components/charts/xy/charts.xy.component.html +++ b/src/app/components/charts/xy/charts.xy.component.html @@ -1,2 +1,5 @@ -
- + +
+
diff --git a/src/app/components/charts/xy/charts.xy.component.ts b/src/app/components/charts/xy/charts.xy.component.ts index 7947b1beb..a8b376063 100644 --- a/src/app/components/charts/xy/charts.xy.component.ts +++ b/src/app/components/charts/xy/charts.xy.component.ts @@ -24,6 +24,7 @@ import { LoggerService } from '../../../services/logger.service'; import { AppColors } from '../../../services/color/app.colors'; import { ActivityTypes } from '@sports-alliance/sports-lib'; import { ChartDataCategoryTypes, TimeIntervals } from '@sports-alliance/sports-lib'; +import { normalizeUnitDerivedTypeLabel } from '../../../helpers/stat-label.helper'; @Component({ @@ -64,7 +65,11 @@ export class ChartsXYComponent extends DashboardChartAbstractDirective implement chartTitle.adapter.add('text', (text, target, key) => { const data = target.parent.parent.parent.parent['data']; const value = this.getAggregateData(data, this.chartDataValueType); - return `[font-family: 'Barlow Condensed', sans-serif font-size: 1.4em]${value.getDisplayType()}[/] [bold font-family: 'Barlow Condensed', sans-serif font-size: 1.3em]${value.getDisplayValue()}${value.getDisplayUnit()}[/] (${this.chartDataValueType}${this.chartDataCategoryType === ChartDataCategoryTypes.DateType ? ` @ ${TimeIntervals[this.chartDataTimeInterval]}` : ``})`; + if (!value) { + return `[font-family: 'Barlow Condensed', sans-serif font-size: 1.4em]${this.chartDataValueType || 'Value'}[/] [bold font-family: 'Barlow Condensed', sans-serif font-size: 1.3em]--[/]`; + } + const normalizedLabel = normalizeUnitDerivedTypeLabel(value.getType(), value.getDisplayType()); + return `[font-family: 'Barlow Condensed', sans-serif font-size: 1.4em]${normalizedLabel}[/] [bold font-family: 'Barlow Condensed', sans-serif font-size: 1.3em]${value.getDisplayValue()}${value.getDisplayUnit()}[/] (${this.chartDataValueType}${this.chartDataCategoryType === ChartDataCategoryTypes.DateType ? ` @ ${TimeIntervals[this.chartDataTimeInterval]}` : ``})`; }); chartTitle.marginTop = core.percent(20); const categoryAxis = chart.xAxes.push(this.getCategoryAxis(this.chartDataCategoryType, this.chartDataTimeInterval, charts)); @@ -98,7 +103,10 @@ export class ChartsXYComponent extends DashboardChartAbstractDirective implement valueAxis.numberFormatter.numberFormat = `#`; // valueAxis.numberFormatter.numberFormat = `#${DynamicDataLoader.getDataClassFromDataType(this.chartDataType).unit}`; valueAxis.renderer.labels.template.adapter.add('text', (text, target) => { - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, Number(text)); + const data = this.getDataInstanceOrNull(text); + if (!data) { + return ''; + } return `[bold font-family: 'Barlow Condensed', sans-serif font-size: 1.0em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` }); valueAxis.renderer.labels.template.adapter.add('dx', (text, target) => { @@ -144,7 +152,10 @@ export class ChartsXYComponent extends DashboardChartAbstractDirective implement if (!target.dataItem || !target.dataItem.dataContext) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, target.dataItem.dataContext[this.chartDataValueType]); + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `{dateX}{categoryX}\n[bold]${this.chartDataValueType}: ${data.getDisplayValue()}${data.getDisplayUnit()}[/b]\n${target.dataItem.dataContext['count'] ? `[bold]${target.dataItem.dataContext['count']}[/b] Activities` : ``}` }); // bullet.filters.push(ChartHelper.getShadowFilter()); @@ -169,7 +180,13 @@ export class ChartsXYComponent extends DashboardChartAbstractDirective implement const categoryLabel = series.bullets.push(new charts.LabelBullet()); categoryLabel.dy = -15; categoryLabel.label.adapter.add('text', (text, target) => { - const data = DynamicDataLoader.getDataInstanceFromDataType(this.chartDataType, Number(target.dataItem.dataContext[this.chartDataValueType])); + if (!target.dataItem || !target.dataItem.dataContext) { + return ''; + } + const data = this.getDataInstanceOrNull(target.dataItem.dataContext[this.chartDataValueType]); + if (!data) { + return ''; + } return `[bold font-family: 'Barlow Condensed', sans-serif font-size: 1.1em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` }); categoryLabel.label.background = new core.RoundedRectangle(); diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.html b/src/app/components/confirmation-dialog/confirmation-dialog.component.html index 21b8d4446..ac563f67c 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.html +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.html @@ -1,8 +1,8 @@ -

{{ data.title }}

+

{{ title }}

-

+

- - - \ No newline at end of file + + + diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts new file mode 100644 index 000000000..069cdca4c --- /dev/null +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts @@ -0,0 +1,107 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { ConfirmationDialogComponent } from './confirmation-dialog.component'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('ConfirmationDialogComponent', () => { + let fixture: ComponentFixture; + let component: ConfirmationDialogComponent; + let dialogRefMock: { close: ReturnType }; + let bottomSheetRefMock: { dismiss: ReturnType }; + + beforeEach(async () => { + dialogRefMock = { close: vi.fn() }; + bottomSheetRefMock = { dismiss: vi.fn() }; + + await TestBed.configureTestingModule({ + declarations: [ConfirmationDialogComponent], + imports: [MatDialogModule, MatButtonModule], + providers: [ + { provide: MatDialogRef, useValue: dialogRefMock }, + { provide: MatBottomSheetRef, useValue: bottomSheetRefMock }, + { provide: MAT_DIALOG_DATA, useValue: null }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should provide generic defaults when no dialog data is passed', () => { + expect(component.title).toBe('Are you sure?'); + expect(component.message).toContain('cannot be undone'); + expect(component.confirmButtonText).toBe('Confirm'); + expect(component.cancelButtonText).toBe('Cancel'); + expect(component.confirmColor).toBe('primary'); + }); + + it('should use custom dialog data values', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + declarations: [ConfirmationDialogComponent], + imports: [MatDialogModule, MatButtonModule], + providers: [ + { provide: MatDialogRef, useValue: dialogRefMock }, + { provide: MatBottomSheetRef, useValue: bottomSheetRefMock }, + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Reimport activity from file?', + message: 'This will replace current activity data.', + confirmText: 'Reimport', + cancelText: 'Keep current', + confirmColor: 'primary', + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmationDialogComponent); + component = fixture.componentInstance; + + expect(component.title).toBe('Reimport activity from file?'); + expect(component.message).toBe('This will replace current activity data.'); + expect(component.confirmButtonText).toBe('Reimport'); + expect(component.cancelButtonText).toBe('Keep current'); + expect(component.confirmColor).toBe('primary'); + }); + + it('should close dialog and bottom-sheet with selected decision', () => { + component.onConfirm(); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); + expect(bottomSheetRefMock.dismiss).toHaveBeenCalledWith(true); + }); + + it('should support label aliases and hide cancel when requested', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + declarations: [ConfirmationDialogComponent], + imports: [MatDialogModule, MatButtonModule], + providers: [ + { provide: MatDialogRef, useValue: dialogRefMock }, + { provide: MatBottomSheetRef, useValue: bottomSheetRefMock }, + { + provide: MAT_DIALOG_DATA, + useValue: { + confirmLabel: 'Delete', + cancelLabel: 'Abort', + confirmColor: 'warn', + showCancel: false, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmationDialogComponent); + component = fixture.componentInstance; + + expect(component.confirmButtonText).toBe('Delete'); + expect(component.cancelButtonText).toBe('Abort'); + expect(component.showCancel).toBe(false); + expect(component.confirmColor).toBe('warn'); + }); +}); diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts index 05db2d363..a314b923f 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts @@ -1,33 +1,70 @@ -import { Component, Inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; +import { Component, inject } from '@angular/core'; +import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; export interface ConfirmationDialogData { - title: string; - message: string; - confirmText?: string; - cancelText?: string; + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + confirmLabel?: string; + cancelLabel?: string; + confirmColor?: 'primary' | 'accent' | 'warn'; + showCancel?: boolean; } @Component({ - selector: 'app-confirmation-dialog', - standalone: true, - imports: [CommonModule, MatDialogModule, MatButtonModule], - templateUrl: './confirmation-dialog.component.html', - styleUrls: ['./confirmation-dialog.component.scss'] + selector: 'app-confirmation-dialog', + templateUrl: './confirmation-dialog.component.html', + styleUrls: ['./confirmation-dialog.component.scss'], + standalone: false }) export class ConfirmationDialogComponent { - constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ConfirmationDialogData - ) { } + private _bottomSheetRef = inject(MatBottomSheetRef, { optional: true }); + private _dialogRef = inject(MatDialogRef, { optional: true }); + private _dialogData = inject(MAT_DIALOG_DATA, { optional: true }); - onConfirm(): void { - this.dialogRef.close(true); + get title(): string { + return this._dialogData?.title || 'Are you sure?'; + } + + get message(): string { + return this._dialogData?.message || 'This action cannot be undone.'; + } + + get confirmButtonText(): string { + return this._dialogData?.confirmLabel || this._dialogData?.confirmText || 'Confirm'; + } + + get cancelButtonText(): string { + return this._dialogData?.cancelLabel || this._dialogData?.cancelText || 'Cancel'; + } + + get confirmColor(): 'primary' | 'accent' | 'warn' { + return this._dialogData?.confirmColor || 'primary'; + } + + get showCancel(): boolean { + if (this._dialogData?.showCancel === false) { + return false; } + return true; + } + + onCancel(): void { + this.respond(false); + } - onCancel(): void { - this.dialogRef.close(false); + onConfirm(): void { + this.respond(true); + } + + private respond(confirmed: boolean): void { + if (this._bottomSheetRef) { + this._bottomSheetRef.dismiss(confirmed); + } + if (this._dialogRef) { + this._dialogRef.close(confirmed); } + } } diff --git a/src/app/components/dashboard/dashboard.component.html b/src/app/components/dashboard/dashboard.component.html index 86d3ef5ea..9d876adc2 100644 --- a/src/app/components/dashboard/dashboard.component.html +++ b/src/app/components/dashboard/dashboard.component.html @@ -1,30 +1,32 @@
- + +
+ @if (user) { + + + } + + @if (user) { + @defer (on idle) { + + + } @placeholder { +
+ } + } -
- @if (user) { - - - } - - - - @if (user) { - - - } - - @if (user) { - - } -
+ @if (user) { + + } +
+
diff --git a/src/app/components/dashboard/dashboard.component.scss b/src/app/components/dashboard/dashboard.component.scss index cb020ed0b..adfeb350d 100644 --- a/src/app/components/dashboard/dashboard.component.scss +++ b/src/app/components/dashboard/dashboard.component.scss @@ -1,12 +1,18 @@ :host { display: block; - min-height: 100vh; + min-height: calc(100vh - var(--qs-effective-top-offset, 0px)); // background: var(--background-color, #f5f7fa); /* REMOVED: Managed by global theme */ /* Fallback or use app var */ // color: #333; /* REMOVED */ } +@supports (height: 100dvh) { + :host { + min-height: calc(100dvh - var(--qs-effective-top-offset, 0px)); + } +} + section.component-container { max-width: 1600px; margin: 0 auto; @@ -32,7 +38,7 @@ app-event-table { } app-event-search { - margin-top: 16px; + // margin-top: 16px; } /* Fade in animation */ @@ -61,4 +67,4 @@ mat-divider { :host-context(.dark-theme) mat-divider { opacity: 0.2; -} \ No newline at end of file +} diff --git a/src/app/components/dashboard/dashboard.component.spec.ts b/src/app/components/dashboard/dashboard.component.spec.ts index 91c6b55b8..44c429f38 100644 --- a/src/app/components/dashboard/dashboard.component.spec.ts +++ b/src/app/components/dashboard/dashboard.component.spec.ts @@ -9,6 +9,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of } from 'rxjs'; import { User } from '@sports-alliance/sports-lib'; +import { DateRanges } from '@sports-alliance/sports-lib'; import { AppUserInterface } from '../../models/app-user.interface'; import { Analytics } from '@angular/fire/analytics'; import { vi, describe, it, expect, beforeEach } from 'vitest'; @@ -110,6 +111,58 @@ describe('DashboardComponent', () => { expect(component.isLoading).toBe(false); }); + it('should attach initial live query when resolver already returned user data', async () => { + mockActivatedRoute.snapshot.data.dashboardData.user = mockUser; + mockActivatedRoute.snapshot.data.dashboardData.events = [{ id: 'event1' }]; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockEventService.getEventsBy).toHaveBeenCalled(); + expect(component.events.length).toBe(1); + }); + + it('should skip only the first identical live emission and then update on subsequent changes', async () => { + const resolvedEvents = [{ id: 'event1' }] as any; + mockActivatedRoute.snapshot.data.dashboardData.user = mockUser; + mockActivatedRoute.snapshot.data.dashboardData.events = resolvedEvents; + + const eventsSubject = new BehaviorSubject([{ id: 'event1' }] as any); + mockEventService.getEventsBy.mockReturnValue(eventsSubject.asObservable()); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.events).toBe(resolvedEvents); + + const updatedEvents = [{ id: 'event1' }, { id: 'event2' }] as any; + eventsSubject.next(updatedEvents); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.events).toEqual(updatedEvents); + expect(component.events).not.toBe(resolvedEvents); + }); + + it('should stay live-reactive after cache-backed resolver data', async () => { + mockActivatedRoute.snapshot.data.dashboardData.user = mockUser; + mockActivatedRoute.snapshot.data.dashboardData.events = [{ id: 'event1' }]; + mockActivatedRoute.snapshot.data.dashboardData.eventsSource = 'cache'; + + const eventsSubject = new BehaviorSubject([{ id: 'event1' }] as any); + mockEventService.getEventsBy.mockReturnValue(eventsSubject.asObservable()); + + fixture.detectChanges(); + await fixture.whenStable(); + + eventsSubject.next([{ id: 'event1' }, { id: 'event2' }] as any); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.events.length).toBe(2); + expect((component.events[1] as any).id).toBe('event2'); + }); + it('should update events when service emits new data', async () => { const eventsSubject = new BehaviorSubject([{ id: 'event1' }]); mockEventService.getEventsBy.mockReturnValue(eventsSubject.asObservable()); @@ -207,4 +260,52 @@ describe('DashboardComponent', () => { fixture.detectChanges(); expect(component.events[0].startDate.getTime()).toBe(date2.getTime()); }); + + it('should restore previous state when persisting dashboard search fails', async () => { + const previousStartDate = new Date('2025-01-01T00:00:00.000Z'); + const previousEndDate = new Date('2025-01-31T23:59:59.000Z'); + const previousActivityTypes = ['running'] as any; + const userForSearch = { + ...mockUser, + settings: { + ...mockUser.settings, + dashboardSettings: { + ...mockUser.settings.dashboardSettings, + includeMergedEvents: true, + dateRange: DateRanges.thisMonth, + startDate: previousStartDate.getTime(), + endDate: previousEndDate.getTime(), + activityTypes: previousActivityTypes + } + } + } as any; + + component.user = userForSearch; + component.searchTerm = 'previous term'; + component.searchStartDate = previousStartDate; + component.searchEndDate = previousEndDate; + + mockUserService.updateUserProperties.mockRejectedValueOnce(new Error('write failed')); + + await component.search({ + searchTerm: 'new term', + startDate: new Date('2025-02-01T00:00:00.000Z'), + endDate: new Date('2025-02-10T23:59:59.000Z'), + dateRange: DateRanges.lastThirtyDays, + activityTypes: ['cycling'] as any, + includeMergedEvents: false + }); + + expect(component.isLoading).toBe(false); + expect((component as any).shouldSearch).toBe(false); + expect(component.searchTerm).toBe('previous term'); + expect(component.searchStartDate).toEqual(previousStartDate); + expect(component.searchEndDate).toEqual(previousEndDate); + expect(component.user.settings.dashboardSettings.includeMergedEvents).toBe(true); + expect(component.user.settings.dashboardSettings.dateRange).toBe(DateRanges.thisMonth); + expect(component.user.settings.dashboardSettings.startDate).toBe(previousStartDate.getTime()); + expect(component.user.settings.dashboardSettings.endDate).toBe(previousEndDate.getTime()); + expect(component.user.settings.dashboardSettings.activityTypes).toEqual(previousActivityTypes); + expect(mockSnackBar.open).toHaveBeenCalledWith('Could not update dashboard filters'); + }); }); diff --git a/src/app/components/dashboard/dashboard.component.ts b/src/app/components/dashboard/dashboard.component.ts index 04be26641..857db3cbb 100644 --- a/src/app/components/dashboard/dashboard.component.ts +++ b/src/app/components/dashboard/dashboard.component.ts @@ -1,6 +1,6 @@ import { Component, OnChanges, OnDestroy, OnInit, inject } from '@angular/core'; import { AppEventService } from '../../services/app.event.service'; -import { asyncScheduler, of, Subscription } from 'rxjs'; +import { merge, of, Subject, Subscription } from 'rxjs'; import { EventInterface } from '@sports-alliance/sports-lib'; import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -10,9 +10,10 @@ import { DateRanges } from '@sports-alliance/sports-lib'; import { Search } from '../event-search/event-search.component'; import { AppUserService } from '../../services/app.user.service'; import { DaysOfTheWeek } from '@sports-alliance/sports-lib'; -import { distinctUntilChanged, map, switchMap, take, tap, throttleTime } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { AppAnalyticsService } from '../../services/app.analytics.service'; import { ActivityTypes } from '@sports-alliance/sports-lib'; +import { LoggerService } from '../../services/logger.service'; import { getDatesForDateRange } from '../../helpers/date-range-helper'; import { WhereFilterOp } from 'firebase/firestore'; @@ -40,7 +41,12 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { public hasMergedEvents = false; private shouldSearch: boolean; + private manualSearchTrigger$ = new Subject(); + private initialLiveReconcilePending = false; + private initialResolvedEventsForReconcile: EventInterface[] = []; + private initialResolvedUserIDForReconcile: string | null = null; private analyticsService = inject(AppAnalyticsService); + private logger = inject(LoggerService); constructor(public authService: AppAuthService, @@ -52,11 +58,16 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { } async ngOnInit() { + const initStart = performance.now(); const resolvedData = this.route.snapshot.data['dashboardData']; if (resolvedData) { + const resolvedDataStart = performance.now(); this.events = resolvedData.events || []; this.user = resolvedData.user; + this.initialLiveReconcilePending = !!resolvedData.user; + this.initialResolvedEventsForReconcile = this.events || []; + this.initialResolvedUserIDForReconcile = resolvedData.user?.uid || null; this.targetUser = resolvedData.targetUser; this.hasMergedEvents = resolvedData.hasMergedEvents ?? this.events?.some(event => event.isMerge) ?? false; this.isLoading = false; @@ -74,6 +85,7 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { } this.startOfTheWeek = this.user.settings.unitSettings?.startOfTheWeek; } + this.logPerf('resolved_dashboard_data', resolvedDataStart, { events: this.events?.length || 0 }); } this.shouldSearch = false; @@ -81,15 +93,18 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { // @todo make this an obsrvbl const userID = this.route.snapshot.paramMap.get('userID'); if (userID && !this.targetUser) { // Only fetch if not resolved + const targetUserFetchStart = performance.now(); try { this.targetUser = await this.userService.getUserByID(userID).pipe(take(1)).toPromise(); + this.logPerf('target_user_fetch', targetUserFetchStart, { userID }); } catch (e) { return this.router.navigate(['dashboard']).then(() => { this.snackBar.open('Page not found'); }); } } - this.dataSubscription = this.authService.user$.pipe(switchMap((user: AppUserInterface | null) => { + this.dataSubscription = merge(this.authService.user$, this.manualSearchTrigger$).pipe(switchMap((user: AppUserInterface | null) => { + const userEmissionStart = performance.now(); if (this.shouldSearch || !this.isInitialized) { this.isLoading = true; @@ -104,7 +119,6 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { } - if (this.user && ( this.user.settings.dashboardSettings.dateRange !== user.settings.dashboardSettings.dateRange || this.user.settings.dashboardSettings.startDate !== user.settings.dashboardSettings.startDate @@ -162,38 +176,69 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { return this.eventService .getEventsBy(this.targetUser ? this.targetUser : user, where, 'startDate', false, limit) .pipe( - distinctUntilChanged((p: EventInterface[], c: EventInterface[]) => { - if (p?.length !== c?.length) return false; - return p.every((event, index) => { - const prev = p[index]; - const curr = c[index]; - return prev.getID() === curr.getID() && - prev.name === curr.name && - prev.startDate?.getTime() === curr.startDate?.getTime(); - }); + distinctUntilChanged((p: EventInterface[], c: EventInterface[]) => this.areEventsEquivalentByIdentity(p, c)), + map((eventsArray: EventInterface[]) => { + if (this.initialLiveReconcilePending && this.initialResolvedUserIDForReconcile !== user.uid) { + this.initialLiveReconcilePending = false; + } + + const shouldAttemptInitialReconcile = this.initialLiveReconcilePending + && this.initialResolvedUserIDForReconcile === user.uid + && !this.shouldSearch; + + if (shouldAttemptInitialReconcile) { + this.initialLiveReconcilePending = false; + const isDuplicateOfResolvedData = this.areEventsEquivalentByIdentity(this.initialResolvedEventsForReconcile, eventsArray); + if (isDuplicateOfResolvedData) { + this.logger.info('[perf] dashboard_skip_initial_live_duplicate', { + events: eventsArray?.length || 0, + userID: user.uid, + }); + return { eventsArray, skipInitialStateUpdate: true }; + } + } + + return { eventsArray, skipInitialStateUpdate: false }; }), - tap((eventsArray: EventInterface[]) => { + tap(({ eventsArray, skipInitialStateUpdate }) => { + if (skipInitialStateUpdate) { + return; + } + this.logPerf('events_listener_emit', userEmissionStart, { incomingEvents: eventsArray?.length || 0 }); this.hasMergedEvents = eventsArray.some(event => event.isMerge); }), - map((eventsArray: EventInterface[]) => { - const t0 = performance.now(); + map(({ eventsArray, skipInitialStateUpdate }) => { + if (skipInitialStateUpdate) { + return null; + } + const filterStart = performance.now(); let filteredEvents = eventsArray; if (!includeMergedEvents) { filteredEvents = filteredEvents.filter(event => !event.isMerge); } if (!user.settings.dashboardSettings.activityTypes || !user.settings.dashboardSettings.activityTypes.length) { + this.logPerf('events_filtering', filterStart, { + includeMergedEvents, + activityTypeFilters: 0, + resultCount: filteredEvents.length, + }); return filteredEvents; } const result = filteredEvents.filter(event => { const hasType = event.getActivityTypesAsArray().some(activityType => user.settings.dashboardSettings.activityTypes.indexOf(ActivityTypes[activityType]) >= 0); return hasType; }); - + this.logPerf('events_filtering', filterStart, { + includeMergedEvents, + activityTypeFilters: user.settings.dashboardSettings.activityTypes.length, + resultCount: result.length, + }); return result; + }), + filter((eventsArray: EventInterface[] | null): eventsArray is EventInterface[] => eventsArray !== null), + map((events) => { + return { events: events, user: user } })) - .pipe(map((events) => { - return { events: events, user: user } - })) })).subscribe((eventsAndUser) => { this.shouldSearch = false; @@ -201,23 +246,59 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { this.user = eventsAndUser.user; this.isLoading = false; this.isInitialized = true; + this.logger.info('[perf] dashboard_state_update', { events: this.events.length }); }); + this.logPerf('dashboard_init', initStart); } async search(search: Search) { + if (!this.user?.settings?.dashboardSettings) { + return; + } + + const previousSearchState = { + searchTerm: this.searchTerm, + searchStartDate: this.searchStartDate, + searchEndDate: this.searchEndDate, + }; + const previousDashboardSettings = { + includeMergedEvents: this.user.settings.dashboardSettings.includeMergedEvents, + dateRange: this.user.settings.dashboardSettings.dateRange, + startDate: this.user.settings.dashboardSettings.startDate, + endDate: this.user.settings.dashboardSettings.endDate, + activityTypes: this.user.settings.dashboardSettings.activityTypes, + }; + this.isLoading = true; this.shouldSearch = true; this.searchTerm = search.searchTerm; this.searchStartDate = search.startDate; this.searchEndDate = search.endDate; - this.user.settings.dashboardSettings.includeMergedEvents = search.includeMergedEvents !== false; - this.user.settings.dashboardSettings.dateRange = search.dateRange; - this.user.settings.dashboardSettings.startDate = search.startDate && search.startDate.getTime(); - this.user.settings.dashboardSettings.endDate = search.endDate && search.endDate.getTime(); - this.user.settings.dashboardSettings.activityTypes = search.activityTypes; - this.analyticsService.logEvent('dashboard_search', { method: DateRanges[search.dateRange] }); - await this.userService.updateUserProperties(this.user, { settings: this.user.settings }) + + try { + this.user.settings.dashboardSettings.includeMergedEvents = search.includeMergedEvents !== false; + this.user.settings.dashboardSettings.dateRange = search.dateRange; + this.user.settings.dashboardSettings.startDate = search.startDate && search.startDate.getTime(); + this.user.settings.dashboardSettings.endDate = search.endDate && search.endDate.getTime(); + this.user.settings.dashboardSettings.activityTypes = search.activityTypes; + this.manualSearchTrigger$.next(this.user); + this.analyticsService.logEvent('dashboard_search', { method: DateRanges[search.dateRange] }); + await this.userService.updateUserProperties(this.user, { settings: this.user.settings }); + } catch (error) { + this.searchTerm = previousSearchState.searchTerm; + this.searchStartDate = previousSearchState.searchStartDate; + this.searchEndDate = previousSearchState.searchEndDate; + this.user.settings.dashboardSettings.includeMergedEvents = previousDashboardSettings.includeMergedEvents; + this.user.settings.dashboardSettings.dateRange = previousDashboardSettings.dateRange; + this.user.settings.dashboardSettings.startDate = previousDashboardSettings.startDate; + this.user.settings.dashboardSettings.endDate = previousDashboardSettings.endDate; + this.user.settings.dashboardSettings.activityTypes = previousDashboardSettings.activityTypes; + this.shouldSearch = false; + this.isLoading = false; + this.snackBar.open('Could not update dashboard filters'); + this.logger.error('[DashboardComponent] Failed to persist dashboard search filters', error); + } } ngOnChanges() { @@ -230,5 +311,53 @@ export class DashboardComponent implements OnInit, OnDestroy, OnChanges { if (this.dataSubscription) { this.dataSubscription.unsubscribe(); } + this.manualSearchTrigger$.complete(); + } + + private logPerf(step: string, start: number, meta?: Record) { + this.logger.info(`[perf] dashboard_${step}`, { + durationMs: Number((performance.now() - start).toFixed(2)), + ...(meta || {}), + }); + } + + private areEventsEquivalentByIdentity(previousEvents: EventInterface[] = [], currentEvents: EventInterface[] = []): boolean { + if (previousEvents?.length !== currentEvents?.length) { + return false; + } + return previousEvents.every((previousEvent, index) => { + const currentEvent = currentEvents[index]; + return this.getEventStableID(previousEvent) === this.getEventStableID(currentEvent) + && previousEvent?.name === currentEvent?.name + && this.getEventStableStartDate(previousEvent) === this.getEventStableStartDate(currentEvent); + }); + } + + private getEventStableID(event: EventInterface | undefined): string | null { + if (!event) { + return null; + } + const eventAny = event as any; + if (typeof eventAny.getID === 'function') { + return eventAny.getID(); + } + return eventAny.id || null; + } + + private getEventStableStartDate(event: EventInterface | undefined): number | null { + if (!event) { + return null; + } + const startDate = (event as any).startDate; + if (startDate instanceof Date) { + return startDate.getTime(); + } + if (startDate && typeof startDate.getTime === 'function') { + return startDate.getTime(); + } + if (typeof startDate === 'number') { + return startDate; + } + return null; } } diff --git a/src/app/components/data-table/data-table-abstract.directive.ts b/src/app/components/data-table/data-table-abstract.directive.ts index ee63da4ab..282825e7e 100644 --- a/src/app/components/data-table/data-table-abstract.directive.ts +++ b/src/app/components/data-table/data-table-abstract.directive.ts @@ -55,22 +55,30 @@ export abstract class DataTableAbstractDirective extends LoadingAbstractDirectiv getStatsRowElement(stats: DataInterface[], activityTypes: string[], unitSettings?: UserUnitSettingsInterface, isMerge: boolean = false): StatRowElement { const statRowElement: StatRowElement = {}; + const statsByType = new Map(); + for (const stat of stats) { + const type = stat.getType(); + if (!statsByType.has(type)) { + statsByType.set(type, stat); + } + } + const getStat = (type: string) => statsByType.get(type); - const distance = stats.find(stat => stat.getType() === DataDistance.type); - const duration = stats.find(stat => stat.getType() === DataDuration.type); - const ascent = stats.find(stat => stat.getType() === DataAscent.type); - const descent = stats.find(stat => stat.getType() === DataDescent.type); - const energy = stats.find(stat => stat.getType() === DataEnergy.type); - const avgPower = stats.find(stat => stat.getType() === DataPowerAvg.type); - const maxPower = stats.find(stat => stat.getType() === DataPowerMax.type); - const avgSpeed = stats.find(stat => stat.getType() === DataSpeedAvg.type); - const heartRateAverage = stats.find(stat => stat.getType() === DataHeartRateAvg.type); - const rpe = stats.find(stat => stat.getType() === DataRPE.type); - const feeling = stats.find(stat => stat.getType() === DataFeeling.type); - const vO2Max = stats.find(stat => stat.getType() === DataVO2Max.type); - const TTE = stats.find(stat => stat.getType() === DataAerobicTrainingEffect.type); - const EPOC = stats.find(stat => stat.getType() === DataPeakEPOC.type); - const recoveryTime = stats.find(stat => stat.getType() === DataRecoveryTime.type); + const distance = getStat(DataDistance.type); + const duration = getStat(DataDuration.type); + const ascent = getStat(DataAscent.type); + const descent = getStat(DataDescent.type); + const energy = getStat(DataEnergy.type); + const avgPower = getStat(DataPowerAvg.type); + const maxPower = getStat(DataPowerMax.type); + const avgSpeed = getStat(DataSpeedAvg.type); + const heartRateAverage = getStat(DataHeartRateAvg.type); + const rpe = getStat(DataRPE.type); + const feeling = getStat(DataFeeling.type); + const vO2Max = getStat(DataVO2Max.type); + const TTE = getStat(DataAerobicTrainingEffect.type); + const EPOC = getStat(DataPeakEPOC.type); + const recoveryTime = getStat(DataRecoveryTime.type); statRowElement[DataDuration.type] = duration ? `${duration.getDisplayValue()}` : ''; statRowElement[DataDistance.type] = distance ? `${distance.getDisplayValue()} ${distance.getDisplayUnit()}` : ''; @@ -92,22 +100,27 @@ export abstract class DataTableAbstractDirective extends LoadingAbstractDirectiv .map(data => `${data.getDisplayValue()}${data.getDisplayUnit()}`) .join(', '); } else { - statRowElement[DataSpeedAvg.type] = activityTypes.reduce((accu, activityType) => { - return [...accu, ...ActivityTypesHelper.averageSpeedDerivedDataTypesToUseForActivityType(ActivityTypes[activityType])] - }, []).reduce((accu, dataType) => { - - // Hide Grade Adjusted Pace from the dashboard event table - if ((typeof DataGradeAdjustedPace !== 'undefined' && dataType === DataGradeAdjustedPace.type) || dataType === 'GradeAdjustedPace' || dataType === 'Average Grade Adjusted Pace') { - return accu; + const speedValues: string[] = []; + for (const activityType of activityTypes) { + const derivedDataTypes = ActivityTypesHelper.averageSpeedDerivedDataTypesToUseForActivityType( + ActivityTypes[activityType as keyof typeof ActivityTypes] + ); + for (const dataType of derivedDataTypes) { + // Hide Grade Adjusted Pace from the dashboard event table + if ((typeof DataGradeAdjustedPace !== 'undefined' && dataType === DataGradeAdjustedPace.type) || dataType === 'GradeAdjustedPace' || dataType === 'Average Grade Adjusted Pace') { + continue; + } + const stat = getStat(dataType); + if (!stat) { + continue; + } + const unitBasedData = DynamicDataLoader.getUnitBasedDataFromDataInstance(stat, unitSettings); + for (const data of unitBasedData) { + speedValues.push(`${data.getDisplayValue()}${data.getDisplayUnit()}`); + } } - const stat = stats.find(iStat => iStat.getType() === dataType); - return stat ? - [...accu, ...DynamicDataLoader.getUnitBasedDataFromDataInstance(stat, unitSettings)] - : accu - }, []).reduce((avs, data) => { - avs.push(`${data.getDisplayValue()}${data.getDisplayUnit()}`); - return avs; - }, []).join(', '); + } + statRowElement[DataSpeedAvg.type] = speedValues.join(', '); } // Add the sorts diff --git a/src/app/components/data-type-icon/data-type-icon.component.html b/src/app/components/data-type-icon/data-type-icon.component.html index fd53c6c8c..aada47ef3 100644 --- a/src/app/components/data-type-icon/data-type-icon.component.html +++ b/src/app/components/data-type-icon/data-type-icon.component.html @@ -1,5 +1,6 @@ @if (getColumnHeaderIcon(dataType)) { {{ getColumnHeaderIcon(dataType) }} @@ -14,4 +15,4 @@
-} \ No newline at end of file +} diff --git a/src/app/components/data-type-icon/data-type-icon.component.spec.ts b/src/app/components/data-type-icon/data-type-icon.component.spec.ts new file mode 100644 index 000000000..5b9c6f705 --- /dev/null +++ b/src/app/components/data-type-icon/data-type-icon.component.spec.ts @@ -0,0 +1,276 @@ +import { + DataAscent, + DataAltitudeAvg, + DataAltitudeMax, + DataAltitudeMin, + DataCadenceMax, + DataCadenceMin, + DataDescent, + DataEnergy, + DataFeeling, + DataGradeAdjustedPaceAvg, + DataPaceAvg, + DataPowerMax, + DataPowerMin, + DataJumpCount, + DataJumpDistance, + DataJumpDistanceAvg, + DataJumpDistanceMax, + DataJumpDistanceMin, + DataJumpHangTimeAvg, + DataJumpHangTimeMax, + DataJumpHangTimeMin, + DataJumpHeightAvg, + DataJumpHeightMax, + DataJumpHeightMin, + DataJumpRotationsAvg, + DataJumpRotationsMax, + DataJumpRotationsMin, + DataJumpScoreAvg, + DataJumpScoreMax, + DataJumpScoreMin, + DataJumpSpeedAvg, + DataJumpSpeedAvgFeetPerMinute, + DataJumpSpeedAvgFeetPerSecond, + DataJumpSpeedAvgKilometersPerHour, + DataJumpSpeedAvgKnots, + DataJumpSpeedAvgMetersPerMinute, + DataJumpSpeedAvgMilesPerHour, + DataJumpSpeedMax, + DataJumpSpeedMaxFeetPerMinute, + DataJumpSpeedMaxFeetPerSecond, + DataJumpSpeedMaxKilometersPerHour, + DataJumpSpeedMaxKnots, + DataJumpSpeedMaxMetersPerMinute, + DataJumpSpeedMaxMilesPerHour, + DataJumpSpeedMin, + DataJumpSpeedMinFeetPerMinute, + DataJumpSpeedMinFeetPerSecond, + DataJumpSpeedMinKilometersPerHour, + DataJumpSpeedMinKnots, + DataJumpSpeedMinMetersPerMinute, + DataJumpSpeedMinMilesPerHour, + DataRPE, + DataTemperatureMax, + DataTemperatureMin, + DataVerticalSpeedAvg, + DataVerticalOscillation, + DataVerticalOscillationAvg, + DataVerticalOscillationMax, + DataVerticalOscillationMin +} from '@sports-alliance/sports-lib'; +import { describe, expect, it } from 'vitest'; +import { EVENT_SUMMARY_METRIC_GROUPS } from '../../constants/event-summary-metric-groups'; +import { DataTypeIconComponent } from './data-type-icon.component'; + +describe('DataTypeIconComponent', () => { + it('should return icons for newly surfaced max/min metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataPowerMax.type)).toBe('bolt'); + expect(component.getColumnHeaderIcon(DataPowerMin.type)).toBe('bolt'); + expect(component.getColumnHeaderIcon(DataCadenceMax.type)).toBe('cadence'); + expect(component.getColumnHeaderIcon(DataCadenceMin.type)).toBe('cadence'); + expect(component.getColumnHeaderIcon(DataTemperatureMax.type)).toBe('device_thermostat'); + expect(component.getColumnHeaderIcon(DataTemperatureMin.type)).toBe('device_thermostat'); + expect(component.getColumnHeaderIcon(DataAltitudeMax.type)).toBe('landscape'); + expect(component.getColumnHeaderIcon(DataAltitudeMin.type)).toBe('landscape'); + expect(component.getColumnHeaderIcon(DataAltitudeAvg.type)).toBe('landscape'); + expect(component.getColumnHeaderIcon(DataVerticalSpeedAvg.type)).toBe('unfold_more_double'); + }); + + it('should return icons for physiological subjective metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataFeeling.type)).toBe('mood'); + expect(component.getColumnHeaderIcon(DataRPE.type)).toBe('fitness_center'); + }); + + it('should map ascent and descent to elevation', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataAscent.type)).toBe('elevation'); + expect(component.getColumnHeaderIcon(DataDescent.type)).toBe('elevation'); + }); + + it('should return mirror class for descent only', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIconClass(DataDescent.type)).toBe('icon-mirror-x'); + expect(component.getColumnHeaderIconClass(DataAscent.type)).toBeNull(); + }); + + it('should provide icon mappings for all configured performance tab metrics', () => { + const component = new DataTypeIconComponent(); + const performanceGroup = EVENT_SUMMARY_METRIC_GROUPS.find((group) => group.id === 'performance'); + const performanceMetricTypes = performanceGroup?.metricTypes || []; + + expect(performanceMetricTypes.length).toBeGreaterThan(0); + performanceMetricTypes.forEach((metricType) => { + expect(component.getColumnHeaderIcon(metricType) || component.getColumnHeaderSVGIcon(metricType)).toBeTruthy(); + }); + }); + + it('should provide icon mappings for ascent and descent time', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Ascent Time')).toBe('elevation'); + expect(component.getColumnHeaderIcon('Descent Time')).toBe('elevation'); + }); + + it('should provide icon mappings for requested environment metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Average Absolute Pressure')).toBe('compress'); + expect(component.getColumnHeaderIcon('Minimum Absolute Pressure')).toBe('compress'); + expect(component.getColumnHeaderIcon('Maximum Absolute Pressure')).toBe('compress'); + expect(component.getColumnHeaderIcon('Average Grade')).toBe('tools_level'); + expect(component.getColumnHeaderIcon('Minimum Grade')).toBe('tools_level'); + expect(component.getColumnHeaderIcon('Maximum Grade')).toBe('tools_level'); + }); + + it('should provide icon mappings for requested performance run-dynamics metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Average Ground Contact Time')).toBe('step_over'); + expect(component.getColumnHeaderIcon('Minimum Ground Contact Time')).toBe('step_over'); + expect(component.getColumnHeaderIcon('Maximum Ground Contact Time')).toBe('step_over'); + expect(component.getColumnHeaderIcon(DataVerticalOscillation.type)).toBe('swap_vert'); + expect(component.getColumnHeaderIcon(DataVerticalOscillationAvg.type)).toBe('swap_vert'); + expect(component.getColumnHeaderIcon(DataVerticalOscillationMin.type)).toBe('swap_vert'); + expect(component.getColumnHeaderIcon(DataVerticalOscillationMax.type)).toBe('swap_vert'); + expect(component.getColumnHeaderIcon('Vertical Oscillation')).toBe('swap_vert'); + expect(component.getColumnHeaderIcon('Average Vertical Ratio')).toBe('arrows_outward'); + expect(component.getColumnHeaderIcon('Average Leg Stiffness')).toBe('accessibility_new'); + expect(component.getColumnHeaderIcon('Stance Time')).toBe('step_over'); + expect(component.getColumnHeaderIcon('Stance Time Balance Left')).toBe('step_over'); + }); + + it('should provide icon mappings for grit and flow metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Flow')).toBe('water'); + expect(component.getColumnHeaderIcon('Average Flow')).toBe('automation'); + expect(component.getColumnHeaderIcon('Avg Flow')).toBe('water'); + expect(component.getColumnHeaderIcon('Total Flow')).toBe('water'); + expect(component.getColumnHeaderIcon('Grit')).toBe('cheer'); + expect(component.getColumnHeaderIcon('Avg Grit')).toBe('cheer'); + expect(component.getColumnHeaderIcon('Total Grit')).toBe('cheer'); + }); + + it('should provide icon mappings for FTP', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('FTP')).toBe('recent_patient'); + expect(component.getColumnHeaderIcon('CriticalPower')).toBe('offline_bolt'); + expect(component.getColumnHeaderIcon('Power Normalized')).toBe('electric_bolt'); + }); + + it('should provide icon mapping for Jump Count', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataJumpCount.type)).toBe('123'); + }); + + it('should provide icon mappings for jump stat families', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataJumpDistance.type)).toBe('straighten'); + expect(component.getColumnHeaderIcon(DataJumpDistanceAvg.type)).toBe('straighten'); + expect(component.getColumnHeaderIcon(DataJumpDistanceMin.type)).toBe('straighten'); + expect(component.getColumnHeaderIcon(DataJumpDistanceMax.type)).toBe('straighten'); + expect(component.getColumnHeaderIcon(DataJumpHangTimeAvg.type)).toBe('timer_arrow_up'); + expect(component.getColumnHeaderIcon(DataJumpHangTimeMin.type)).toBe('timer_arrow_up'); + expect(component.getColumnHeaderIcon(DataJumpHangTimeMax.type)).toBe('timer_arrow_up'); + expect(component.getColumnHeaderIcon(DataJumpHeightAvg.type)).toBe('height'); + expect(component.getColumnHeaderIcon(DataJumpHeightMin.type)).toBe('height'); + expect(component.getColumnHeaderIcon(DataJumpHeightMax.type)).toBe('height'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvg.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMin.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMax.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgKilometersPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgMilesPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgFeetPerSecond.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgMetersPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgFeetPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedAvgKnots.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinKilometersPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinMilesPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinFeetPerSecond.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinMetersPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinFeetPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMinKnots.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxKilometersPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxMilesPerHour.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxFeetPerSecond.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxMetersPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxFeetPerMinute.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpSpeedMaxKnots.type)).toBe('speed'); + expect(component.getColumnHeaderIcon(DataJumpRotationsAvg.type)).toBe('autorenew'); + expect(component.getColumnHeaderIcon(DataJumpRotationsMin.type)).toBe('autorenew'); + expect(component.getColumnHeaderIcon(DataJumpRotationsMax.type)).toBe('autorenew'); + expect(component.getColumnHeaderIcon(DataJumpScoreAvg.type)).toBe('military_tech'); + expect(component.getColumnHeaderIcon(DataJumpScoreMin.type)).toBe('military_tech'); + expect(component.getColumnHeaderIcon(DataJumpScoreMax.type)).toBe('military_tech'); + }); + + it('should provide icon mapping for Avg VAM', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Avg VAM')).toBe('trending_up'); + }); + + it('should provide icon mappings for GNSS and Stryd distance', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('GNSS Distance')).toBe('satellite_alt'); + expect(component.getColumnHeaderIcon('Distance (Stryd)')).toBe('route'); + }); + + it('should provide icon mappings for pace and grade adjusted pace metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataPaceAvg.type)).toBe('steps'); + expect(component.getColumnHeaderIcon('Effort Pace')).toBe('steps'); + expect(component.getColumnHeaderIcon(DataGradeAdjustedPaceAvg.type)).toBe('steps'); + expect(component.getColumnHeaderIcon('Minimum Grade Adjusted Pace')).toBe('steps'); + expect(component.getColumnHeaderIcon('Maximum Grade Adjusted Pace')).toBe('steps'); + }); + + it('should provide icon mappings for respiration rate metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Avg Respiration Rate')).toBe('pulmonology'); + expect(component.getColumnHeaderIcon('Min Respiration Rate')).toBe('pulmonology'); + expect(component.getColumnHeaderIcon('Max Respiration Rate')).toBe('pulmonology'); + }); + + it('should provide icon mapping for anaerobic training effect', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Anaerobic Training Effect')).toBe('cardio_load'); + }); + + it('should provide icon mappings for requested device metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon('Average EVPE')).toBe('monitor_heart'); + expect(component.getColumnHeaderIcon('Average EHPE')).toBe('monitor_heart'); + expect(component.getColumnHeaderIcon('Average Satellite 5 Best SNR')).toBe('satellite_alt'); + expect(component.getColumnHeaderIcon('Average Number of Satellites')).toBe('satellite_alt'); + expect(component.getColumnHeaderIcon('Battery Charge')).toBe('battery_full'); + expect(component.getColumnHeaderIcon('Battery Consumption')).toBe('battery_alert'); + expect(component.getColumnHeaderIcon('Battery Current')).toBe('electric_bolt'); + }); + + it('should provide icon mappings for physiological profile metrics', () => { + const component = new DataTypeIconComponent(); + + expect(component.getColumnHeaderIcon(DataEnergy.type)).toBe('metabolism'); + expect(component.getColumnHeaderIcon('Weight')).toBe('monitor_weight'); + expect(component.getColumnHeaderIcon('Height')).toBe('height'); + expect(component.getColumnHeaderIcon('Gender')).toBe('wc'); + expect(component.getColumnHeaderIcon('Fitness Age')).toBe('cake'); + expect(component.getColumnHeaderIcon('Age')).toBe('cake'); + }); +}); diff --git a/src/app/components/data-type-icon/data-type-icon.component.ts b/src/app/components/data-type-icon/data-type-icon.component.ts index 682aa9773..89980e134 100644 --- a/src/app/components/data-type-icon/data-type-icon.component.ts +++ b/src/app/components/data-type-icon/data-type-icon.component.ts @@ -6,7 +6,16 @@ import { DataVO2Max } from '@sports-alliance/sports-lib'; import { DataDeviceNames } from '@sports-alliance/sports-lib'; import { DataActivityTypes } from '@sports-alliance/sports-lib'; import { DataPowerAvg } from '@sports-alliance/sports-lib'; +import { DataPowerMax } from '@sports-alliance/sports-lib'; +import { DataPowerMin } from '@sports-alliance/sports-lib'; +import { DataPower } from '@sports-alliance/sports-lib'; +import { DataPowerLeft } from '@sports-alliance/sports-lib'; +import { DataPowerRight } from '@sports-alliance/sports-lib'; +import { DataAccumulatedPower } from '@sports-alliance/sports-lib'; +import { DataAirPower } from '@sports-alliance/sports-lib'; import { DataCadenceAvg } from '@sports-alliance/sports-lib'; +import { DataCadenceMax } from '@sports-alliance/sports-lib'; +import { DataCadenceMin } from '@sports-alliance/sports-lib'; import { DataSpeedAvg, DataSpeedAvgFeetPerMinute, DataSpeedAvgFeetPerSecond, DataSpeedAvgKilometersPerHour, DataSpeedAvgKnots, DataSpeedAvgMetersPerMinute, @@ -15,12 +24,15 @@ import { import { DataPaceAvg, DataPaceAvgMinutesPerMile } from '@sports-alliance/sports-lib'; import { DataSwimPaceAvg, DataSwimPaceAvgMinutesPer100Yard } from '@sports-alliance/sports-lib'; import { DataTemperatureAvg } from '@sports-alliance/sports-lib'; +import { DataTemperatureMax } from '@sports-alliance/sports-lib'; +import { DataTemperatureMin } from '@sports-alliance/sports-lib'; import { DataAscent } from '@sports-alliance/sports-lib'; import { DataDescent } from '@sports-alliance/sports-lib'; import { DataHeartRateAvg } from '@sports-alliance/sports-lib'; import { DataEnergy } from '@sports-alliance/sports-lib'; import { DataAltitudeMax } from '@sports-alliance/sports-lib'; import { DataAltitudeMin } from '@sports-alliance/sports-lib'; +import { DataAltitudeAvg } from '@sports-alliance/sports-lib'; import { DataVerticalSpeedAvg, DataVerticalSpeedAvgFeetPerHour, @@ -29,7 +41,8 @@ import { DataVerticalSpeedAvgKilometerPerHour, DataVerticalSpeedAvgMetersPerHour, DataVerticalSpeedAvgMetersPerMinute, - DataVerticalSpeedAvgMilesPerHour + DataVerticalSpeedAvgMilesPerHour, + DataVerticalSpeedMax } from '@sports-alliance/sports-lib'; import { DataAerobicTrainingEffect } from '@sports-alliance/sports-lib'; import { DataPeakEPOC } from '@sports-alliance/sports-lib'; @@ -49,6 +62,50 @@ import { DataMovingTime } from '@sports-alliance/sports-lib'; import { DataRecoveryTime } from '@sports-alliance/sports-lib'; import { DataHeartRateMin } from '@sports-alliance/sports-lib'; import { DataHeartRateMax } from '@sports-alliance/sports-lib'; +import { DataFeeling } from '@sports-alliance/sports-lib'; +import { DataRPE } from '@sports-alliance/sports-lib'; +import { DataJumpCount } from '@sports-alliance/sports-lib'; +import { DataJumpDistance } from '@sports-alliance/sports-lib'; +import { DataJumpDistanceAvg } from '@sports-alliance/sports-lib'; +import { DataJumpDistanceMax } from '@sports-alliance/sports-lib'; +import { DataJumpDistanceMin } from '@sports-alliance/sports-lib'; +import { DataJumpHangTimeAvg } from '@sports-alliance/sports-lib'; +import { DataJumpHangTimeMax } from '@sports-alliance/sports-lib'; +import { DataJumpHangTimeMin } from '@sports-alliance/sports-lib'; +import { DataJumpHeightAvg } from '@sports-alliance/sports-lib'; +import { DataJumpHeightMax } from '@sports-alliance/sports-lib'; +import { DataJumpHeightMin } from '@sports-alliance/sports-lib'; +import { DataJumpRotationsAvg } from '@sports-alliance/sports-lib'; +import { DataJumpRotationsMax } from '@sports-alliance/sports-lib'; +import { DataJumpRotationsMin } from '@sports-alliance/sports-lib'; +import { DataJumpScoreAvg } from '@sports-alliance/sports-lib'; +import { DataJumpScoreMax } from '@sports-alliance/sports-lib'; +import { DataJumpScoreMin } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvg } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgFeetPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgFeetPerSecond } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgKilometersPerHour } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgKnots } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgMetersPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedAvgMilesPerHour } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMax } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxFeetPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxFeetPerSecond } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxKilometersPerHour } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxKnots } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxMetersPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMaxMilesPerHour } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMin } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinFeetPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinFeetPerSecond } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinKilometersPerHour } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinKnots } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinMetersPerMinute } from '@sports-alliance/sports-lib'; +import { DataJumpSpeedMinMilesPerHour } from '@sports-alliance/sports-lib'; +import { DataVerticalOscillation } from '@sports-alliance/sports-lib'; +import { DataVerticalOscillationAvg } from '@sports-alliance/sports-lib'; +import { DataVerticalOscillationMax } from '@sports-alliance/sports-lib'; +import { DataVerticalOscillationMin } from '@sports-alliance/sports-lib'; @Component({ selector: 'app-data-type-icon', @@ -63,6 +120,14 @@ export class DataTypeIconComponent { @Input() size: string; @Input() vAlign: string; + getColumnHeaderIconClass(statName: string): string | null { + if (statName === DataDescent.type) { + return 'icon-mirror-x'; + } + + return null; + } + getColumnHeaderIcon(statName): string { switch (statName) { case DataDistance.type: @@ -78,13 +143,55 @@ export class DataTypeIconComponent { case 'privacy': return 'visibility'; case DataPowerAvg.type: + case DataPowerMax.type: + case DataPowerMin.type: + case DataPower.type: + case 'Form Power': return 'bolt'; + case 'FTP': + return 'recent_patient'; + case 'CriticalPower': + return 'offline_bolt'; + case DataPowerLeft.type: + case 'Power Pedal Smoothness Left': + case 'Power Torque Effectiveness Left': + return 'keyboard_double_arrow_left'; + case DataPowerRight.type: + case 'Power Pedal Smoothness Right': + case 'Power Torque Effectiveness Right': + return 'keyboard_double_arrow_right'; + case DataAccumulatedPower.type: + case 'Power Work': + return 'stacked_bar_chart'; + case DataAirPower.type: + case 'Average Air Power': + case 'Maximum Air Power': + case 'Minimum Air Power': + return 'air'; + case 'Power Normalized': + return 'electric_bolt'; + case 'Power Intensity Factor': + return 'multiline_chart'; + case 'Power Training Stress Score': + return 'monitor_heart'; + case 'PowerWattsPerKg': + return 'monitor_weight'; + case 'WPrime': + return 'battery_charging_full'; + case 'Power Pod': + return 'sensors'; + case 'Power Zone Target': + return 'track_changes'; case DataCadenceAvg.type: - return 'cached'; + case DataCadenceMax.type: + case DataCadenceMin.type: + return 'cadence'; case DataAltitudeMax.type: - return 'vertical_align_top'; + return 'landscape'; case DataAltitudeMin.type: - return 'vertical_align_bottom'; + return 'landscape'; + case DataAltitudeAvg.type: + return 'landscape'; case DataVerticalSpeedAvg.type: case DataVerticalSpeedAvgFeetPerHour.type: case DataVerticalSpeedAvgFeetPerMinute.type: @@ -93,7 +200,8 @@ export class DataTypeIconComponent { case DataVerticalSpeedAvgMilesPerHour.type: case DataVerticalSpeedAvgMetersPerHour.type: case DataVerticalSpeedAvgMetersPerMinute.type: - return 'vertical_align_center'; + case DataVerticalSpeedMax.type: + return 'unfold_more_double'; case DataSpeedAvg.type: case DataSpeedAvgKilometersPerHour.type: case DataSpeedAvgMilesPerHour.type: @@ -104,9 +212,25 @@ export class DataTypeIconComponent { return 'speed'; case DataPaceAvg.type: case DataPaceAvgMinutesPerMile.type: - return 'directions_run'; + case 'Effort Pace': + return 'steps'; + case 'Average VAM': + case 'Avg VAM': + return 'trending_up'; case DataTemperatureAvg.type: + case DataTemperatureMax.type: + case DataTemperatureMin.type: return 'device_thermostat'; + case 'Absolute Pressure': + case 'Average Absolute Pressure': + case 'Minimum Absolute Pressure': + case 'Maximum Absolute Pressure': + return 'compress'; + case 'Grade': + case 'Average Grade': + case 'Minimum Grade': + case 'Maximum Grade': + return 'tools_level'; case DataRecoveryTime.type: return 'update'; case DataVO2Max.type: @@ -126,7 +250,12 @@ export class DataTypeIconComponent { case 'Software Info': return 'system_update_alt'; case 'Battery Level': + case 'Battery Charge': return 'battery_full'; + case 'Battery Consumption': + return 'battery_alert'; + case 'Battery Current': + return 'electric_bolt'; case 'Battery Voltage': return 'bolt'; case 'Product I. D.': @@ -147,27 +276,156 @@ export class DataTypeIconComponent { case 'Cumulative Operating Time': return 'timer'; case DataAscent.type: + case 'Ascent Time': return 'elevation'; case DataDescent.type: - return 'trending_down'; + case 'Descent Time': + return 'elevation'; case DataHeartRateAvg.type: case DataHeartRateMax.type: case DataHeartRateMin.type: return 'ecg_heart'; + case 'Average Respiration Rate': + case 'Minimum Respiration Rate': + case 'Maximum Respiration Rate': + case 'Avg Respiration Rate': + case 'Min Respiration Rate': + case 'Max Respiration Rate': + return 'pulmonology'; + case DataFeeling.type: + return 'mood'; + case DataRPE.type: + return 'fitness_center'; + case 'Weight': + return 'monitor_weight'; + case 'Height': + return 'height'; + case 'Gender': + return 'wc'; + case 'Fitness Age': + case 'Age': + return 'cake'; case DataEnergy.type: - return 'bolt'; + return 'metabolism'; case DataSwimPaceAvg.type: case DataSwimPaceAvgMinutesPer100Yard.type: return 'pool'; case DataAerobicTrainingEffect.type: + case 'Anaerobic Training Effect': return 'cardio_load'; case DataMovingTime.type: return 'pace'; case DataPeakEPOC.type: return null; + case 'EPOC': + case 'EVPE': + case 'Average EVPE': + case 'Minimum EVPE': + case 'Maximum EVPE': + case 'EHPE': + case 'Average EHPE': + case 'Minimum EHPE': + case 'Maximum EHPE': + return 'monitor_heart'; + case 'Flow': + case 'Avg Flow': + case 'Total Flow': + return 'water'; + case 'Average Flow': + return 'automation'; + case 'Grit': + case 'Average Grit': + case 'Avg Grit': + case 'Total Grit': + return 'cheer'; + case DataJumpCount.type: + return '123'; + case DataJumpDistance.type: + case DataJumpDistanceAvg.type: + case DataJumpDistanceMin.type: + case DataJumpDistanceMax.type: + return 'straighten'; + case DataJumpHangTimeAvg.type: + case DataJumpHangTimeMin.type: + case DataJumpHangTimeMax.type: + return 'timer_arrow_up'; + case DataJumpHeightAvg.type: + case DataJumpHeightMin.type: + case DataJumpHeightMax.type: + return 'height'; + case DataJumpSpeedAvg.type: + case DataJumpSpeedMin.type: + case DataJumpSpeedMax.type: + case DataJumpSpeedAvgKilometersPerHour.type: + case DataJumpSpeedAvgMilesPerHour.type: + case DataJumpSpeedAvgFeetPerSecond.type: + case DataJumpSpeedAvgMetersPerMinute.type: + case DataJumpSpeedAvgFeetPerMinute.type: + case DataJumpSpeedAvgKnots.type: + case DataJumpSpeedMinKilometersPerHour.type: + case DataJumpSpeedMinMilesPerHour.type: + case DataJumpSpeedMinFeetPerSecond.type: + case DataJumpSpeedMinMetersPerMinute.type: + case DataJumpSpeedMinFeetPerMinute.type: + case DataJumpSpeedMinKnots.type: + case DataJumpSpeedMaxKilometersPerHour.type: + case DataJumpSpeedMaxMilesPerHour.type: + case DataJumpSpeedMaxFeetPerSecond.type: + case DataJumpSpeedMaxMetersPerMinute.type: + case DataJumpSpeedMaxFeetPerMinute.type: + case DataJumpSpeedMaxKnots.type: + return 'speed'; + case DataJumpRotationsAvg.type: + case DataJumpRotationsMin.type: + case DataJumpRotationsMax.type: + return 'autorenew'; + case DataJumpScoreAvg.type: + case DataJumpScoreMin.type: + case DataJumpScoreMax.type: + return 'military_tech'; + case 'Distance (Stryd)': + return 'route'; + case 'GNSS Distance': + return 'satellite_alt'; + case 'Average Ground Contact Time': + case 'Minimum Ground Contact Time': + case 'Maximum Ground Contact Time': + case 'Stance Time': + case 'Stance Time Balance Left': + case 'Stance Time Balance Right': + case 'Ground Contact Time Balance Left': + case 'Ground Contact Time Balance Right': + return 'step_over'; + case DataVerticalOscillation.type: + case DataVerticalOscillationAvg.type: + case DataVerticalOscillationMin.type: + case DataVerticalOscillationMax.type: + case 'Vertical Oscillation': + return 'swap_vert'; + case 'Vertical Ratio': + case 'Average Vertical Ratio': + case 'Minimum Vertical Ratio': + case 'Maximum Vertical Ratio': + return 'arrows_outward'; + case 'Leg Stiffness': + case 'Average Leg Stiffness': + case 'Minimum Leg Stiffness': + case 'Maximum Leg Stiffness': + return 'accessibility_new'; + case 'Satellite 5 Best SNR': + case 'Average Satellite 5 Best SNR': + case 'Minimum Satellite 5 Best SNR': + case 'Maximum Satellite 5 Best SNR': + case 'Number of Satellites': + case 'Average Number of Satellites': + case 'Minimum Number of Satellites': + case 'Maximum Number of Satellites': + return 'satellite_alt'; case DataGradeAdjustedPaceAvg.type: case DataGradeAdjustedPaceAvgMinutesPerMile.type: - return 'directions_run'; + case 'Minimum Grade Adjusted Pace': + case 'Maximum Grade Adjusted Pace': + return 'steps'; case DataGradeAdjustedSpeedAvg.type: case DataGradeAdjustedSpeedAvgFeetPerMinute.type: case DataGradeAdjustedSpeedAvgFeetPerSecond.type: @@ -175,6 +433,8 @@ export class DataTypeIconComponent { case DataGradeAdjustedSpeedAvgMetersPerMinute.type: case DataGradeAdjustedSpeedAvgMilesPerHour.type: case DataGradeAdjustedSpeedAvgKnots.type: + case 'Minimum Grade Adjusted Speed': + case 'Maximum Grade Adjusted Speed': return 'speed'; default: return null; diff --git a/src/app/components/delete-confirmation/delete-confirmation.component.ts b/src/app/components/delete-confirmation/delete-confirmation.component.ts deleted file mode 100644 index b5adc4716..000000000 --- a/src/app/components/delete-confirmation/delete-confirmation.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, inject, Optional } from '@angular/core'; -import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; -import { MatDialogRef } from '@angular/material/dialog'; - -@Component({ - selector: 'app-delete-confirmation', - templateUrl: 'delete-confirmation.html', - standalone: false -}) -export class DeleteConfirmationComponent { - private _bottomSheetRef = inject(MatBottomSheetRef, { optional: true }); - private _dialogRef = inject(MatDialogRef, { optional: true }); - - shouldDelete(shouldDelete: boolean, event: Event): void { - event.preventDefault(); - if (this._bottomSheetRef) { - this._bottomSheetRef.dismiss(shouldDelete); - } - if (this._dialogRef) { - this._dialogRef.close(shouldDelete); - } - } -} diff --git a/src/app/components/delete-confirmation/delete-confirmation.html b/src/app/components/delete-confirmation/delete-confirmation.html deleted file mode 100644 index 7cf693610..000000000 --- a/src/app/components/delete-confirmation/delete-confirmation.html +++ /dev/null @@ -1,8 +0,0 @@ -

Are you sure you want to delete?

- -

All data will be permanently deleted. This operation cannot be undone.

-
- - - - \ No newline at end of file diff --git a/src/app/components/event-actions/event.actions.component.css b/src/app/components/event-actions/event.actions.component.css index 8f9223d1b..162c37996 100644 --- a/src/app/components/event-actions/event.actions.component.css +++ b/src/app/components/event-actions/event.actions.component.css @@ -5,9 +5,26 @@ } mat-icon.toolTip{ - margin-left: 0.5em; font-size: 18px; width: 18px; height: 18px; - vertical-align: sub; +} + +.regenerate-stat-item { + margin-top: 6px; +} + +.reimport-stat-item { + margin-top: 2px; +} + +.menu-item-with-tooltip { + display: inline-flex; + align-items: center; + gap: 6px; + line-height: 1.2; +} + +.menu-item-tooltip-wrapper { + display: block; } diff --git a/src/app/components/event-actions/event.actions.component.html b/src/app/components/event-actions/event.actions.component.html index d0690b4da..44c639af2 100644 --- a/src/app/components/event-actions/event.actions.component.html +++ b/src/app/components/event-actions/event.actions.component.html @@ -1,7 +1,7 @@ - + } - - - - + - \ No newline at end of file + diff --git a/src/app/components/event-actions/event.actions.component.spec.ts b/src/app/components/event-actions/event.actions.component.spec.ts index 0ae3d2e1b..1aee487f1 100644 --- a/src/app/components/event-actions/event.actions.component.spec.ts +++ b/src/app/components/event-actions/event.actions.component.spec.ts @@ -13,9 +13,12 @@ import { AppWindowService } from '../../services/app.window.service'; import { Clipboard } from '@angular/cdk/clipboard'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { AppAnalyticsService } from '../../services/app.analytics.service'; +import { AppEventReprocessService, ReprocessError } from '../../services/app.event-reprocess.service'; +import { AppProcessingService } from '../../services/app.processing.service'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { MatMenuModule } from '@angular/material/menu'; +import { of } from 'rxjs'; vi.mock('@angular/fire/analytics', () => ({ Analytics: class { }, @@ -27,8 +30,11 @@ describe('EventActionsComponent', () => { let component: EventActionsComponent; let fixture: ComponentFixture; let mockEventService: any; + let mockEventReprocessService: any; + let mockProcessingService: any; let mockFileService: any; let mockSnackBar: any; + let mockDialog: any; beforeEach(async () => { mockEventService = { @@ -36,6 +42,17 @@ describe('EventActionsComponent', () => { getEventMetaData: vi.fn(), getEventAsJSONBloB: vi.fn(), getEventAsGPXBloB: vi.fn(), + writeAllEventData: vi.fn().mockResolvedValue(true), + }; + mockEventReprocessService = { + regenerateEventStatistics: vi.fn().mockResolvedValue({ event: null }), + reimportEventFromOriginalFiles: vi.fn().mockResolvedValue({ event: null }), + }; + mockProcessingService = { + addJob: vi.fn().mockReturnValue('job-id'), + updateJob: vi.fn(), + completeJob: vi.fn(), + failJob: vi.fn(), }; mockFileService = { downloadAsZip: vi.fn(), @@ -65,18 +82,25 @@ describe('EventActionsComponent', () => { mockSnackBar = { open: vi.fn(), }; + mockDialog = { + open: vi.fn().mockReturnValue({ + afterClosed: () => of(true), + }), + }; await TestBed.configureTestingModule({ declarations: [EventActionsComponent], imports: [HttpClientTestingModule, MatMenuModule], providers: [ { provide: AppEventService, useValue: mockEventService }, + { provide: AppEventReprocessService, useValue: mockEventReprocessService }, + { provide: AppProcessingService, useValue: mockProcessingService }, { provide: AppFileService, useValue: mockFileService }, { provide: MatSnackBar, useValue: mockSnackBar }, { provide: Analytics, useValue: null }, // Mock Analytics { provide: Auth, useValue: { currentUser: { uid: 'test-user' } } }, // Mock Auth { provide: Router, useValue: { navigate: vi.fn() } }, - { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: MatDialog, useValue: mockDialog }, { provide: MatBottomSheet, useValue: { open: vi.fn() } }, { provide: AppSharingService, useValue: { getShareURLForEvent: vi.fn() } }, { provide: AppWindowService, useValue: { windowRef: { open: vi.fn() } } }, @@ -179,6 +203,19 @@ describe('EventActionsComponent', () => { expect(args[0]).toBe(mockBlob); expect(args[2]).toBe('json'); }); + + it('should show snackbar and avoid file download when JSON generation fails', async () => { + mockEventService.getEventAsJSONBloB.mockRejectedValue(new Error('hydrate failed')); + + await component.downloadJSON(); + + expect(mockFileService.downloadFile).not.toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith( + 'Could not download JSON file', + undefined, + { duration: 3000 }, + ); + }); }); describe('downloadGPX', () => { @@ -194,10 +231,23 @@ describe('EventActionsComponent', () => { expect(args[0]).toBe(mockBlob); expect(args[2]).toBe('gpx'); }); + + it('should show snackbar and avoid file download when GPX generation fails', async () => { + mockEventService.getEventAsGPXBloB.mockRejectedValue(new Error('hydrate failed')); + + await component.downloadGPX(); + + expect(mockFileService.downloadFile).not.toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith( + 'Could not download GPX file', + undefined, + { duration: 3000 }, + ); + }); }); describe('isHydrated', () => { - it('should return true if first activity has streams', () => { + it('should return true if any activity has streams', () => { const mockActivity = { getAllStreams: () => ['stream1'] }; vi.spyOn(component.event, 'getActivities').mockReturnValue([mockActivity] as any); expect(component.isHydrated()).toBe(true); @@ -208,15 +258,22 @@ describe('EventActionsComponent', () => { expect(component.isHydrated()).toBe(false); }); - it('should return false if first activity has no streams', () => { + it('should return false if no activity has streams', () => { const mockActivity = { getAllStreams: () => [] }; vi.spyOn(component.event, 'getActivities').mockReturnValue([mockActivity] as any); expect(component.isHydrated()).toBe(false); }); + + it('should return true if first activity has no streams but another has streams', () => { + const mockActivity1 = { getAllStreams: () => [] }; + const mockActivity2 = { getAllStreams: () => ['stream1'] }; + vi.spyOn(component.event, 'getActivities').mockReturnValue([mockActivity1, mockActivity2] as any); + expect(component.isHydrated()).toBe(true); + }); }); describe('hasDistance', () => { - it('should return true if first activity has distance stream', () => { + it('should return true if any activity has distance stream', () => { const mockActivity = { hasStreamData: vi.fn().mockReturnValue(true) }; vi.spyOn(component.event, 'getActivities').mockReturnValue([mockActivity] as any); expect(component.hasDistance()).toBe(true); @@ -227,6 +284,13 @@ describe('EventActionsComponent', () => { vi.spyOn(component.event, 'getActivities').mockReturnValue([]); expect(component.hasDistance()).toBe(false); }); + + it('should return true if first activity has no distance but another does', () => { + const mockActivity1 = { hasStreamData: vi.fn().mockReturnValue(false) }; + const mockActivity2 = { hasStreamData: vi.fn().mockReturnValue(true) }; + vi.spyOn(component.event, 'getActivities').mockReturnValue([mockActivity1, mockActivity2] as any); + expect(component.hasDistance()).toBe(true); + }); }); describe('hasPositionalData', () => { @@ -250,4 +314,90 @@ describe('EventActionsComponent', () => { expect(component.hasPositionalData()).toBeFalsy(); }); }); + + describe('reGenerateStatistics', () => { + it('should delegate to AppEventReprocessService and complete processing job', async () => { + await component.reGenerateStatistics(); + expect(mockEventReprocessService.regenerateEventStatistics).toHaveBeenCalledWith( + component.user, + component.event, + expect.objectContaining({ onProgress: expect.any(Function) }), + ); + expect(mockProcessingService.addJob).toHaveBeenCalledWith('process', 'Re-calculating activity statistics...'); + expect(mockProcessingService.completeJob).toHaveBeenCalled(); + }); + + it('should do nothing when confirmation is cancelled', async () => { + mockDialog.open.mockReturnValueOnce({ + afterClosed: () => of(false), + }); + + await component.reGenerateStatistics(); + + expect(mockEventReprocessService.regenerateEventStatistics).not.toHaveBeenCalled(); + expect(mockProcessingService.addJob).not.toHaveBeenCalled(); + }); + + it('should fail processing job and show snackbar on regenerate failure', async () => { + mockEventReprocessService.regenerateEventStatistics.mockRejectedValueOnce( + new ReprocessError('PARSE_FAILED', 'boom'), + ); + + await component.reGenerateStatistics(); + + expect(mockProcessingService.failJob).toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith('Could not parse the original source file.', undefined, { + duration: 4000, + }); + }); + }); + + describe('reImportActivityFromFile', () => { + it('should delegate reimport to AppEventReprocessService', async () => { + (component.event as any).originalFile = { path: 'path/to/file.fit' }; + + await component.reImportActivityFromFile(); + + expect(mockEventReprocessService.reimportEventFromOriginalFiles).toHaveBeenCalledWith( + component.user, + component.event, + expect.objectContaining({ onProgress: expect.any(Function) }), + ); + expect(mockProcessingService.completeJob).toHaveBeenCalled(); + }); + + it('should not reimport when confirmation is cancelled', async () => { + (component.event as any).originalFile = { path: 'path/to/file.fit' }; + mockDialog.open.mockReturnValueOnce({ + afterClosed: () => of(false), + }); + + await component.reImportActivityFromFile(); + + expect(mockEventReprocessService.reimportEventFromOriginalFiles).not.toHaveBeenCalled(); + }); + + it('should not call reimport when original file metadata is missing', async () => { + (component.event as any).originalFile = undefined; + (component.event as any).originalFiles = undefined; + + await component.reImportActivityFromFile(); + + expect(mockEventReprocessService.reimportEventFromOriginalFiles).not.toHaveBeenCalled(); + }); + }); + + describe('reimport visibility helpers', () => { + it('canReimportFromOriginalFile should return true when originalFiles exists', () => { + (component.event as any).originalFiles = [{ path: 'file.fit' }]; + expect(component.canReimportFromOriginalFile()).toBe(true); + }); + + it('canReimportFromOriginalFile should return false and expose disabled reason', () => { + (component.event as any).originalFile = undefined; + (component.event as any).originalFiles = undefined; + expect(component.canReimportFromOriginalFile()).toBe(false); + expect(component.getReimportDisabledReason()).toContain('No original source files'); + }); + }); }); diff --git a/src/app/components/event-actions/event.actions.component.ts b/src/app/components/event-actions/event.actions.component.ts index 57ad6fd9c..e6a744605 100644 --- a/src/app/components/event-actions/event.actions.component.ts +++ b/src/app/components/event-actions/event.actions.component.ts @@ -8,11 +8,10 @@ import { EventExporterJSON } from '@sports-alliance/sports-lib'; import { Privacy } from '@sports-alliance/sports-lib'; import { AppSharingService } from '../../services/app.sharing.service'; import { User } from '@sports-alliance/sports-lib'; -import { DeleteConfirmationComponent } from '../delete-confirmation/delete-confirmation.component'; +import { ConfirmationDialogComponent, ConfirmationDialogData } from '../confirmation-dialog/confirmation-dialog.component'; import { ActivityFormComponent } from '../activity-form/activity.form.component'; import { take } from 'rxjs/operators'; import { Subscription } from 'rxjs'; -import { EventUtilities } from '@sports-alliance/sports-lib'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; import { Clipboard } from '@angular/cdk/clipboard'; @@ -27,9 +26,15 @@ import { Auth, getIdToken } from '@angular/fire/auth'; import { ServiceNames, GarminAPIEventMetaData } from '@sports-alliance/sports-lib'; import { EventExporterGPX } from '@sports-alliance/sports-lib'; import { DataStartPosition } from '@sports-alliance/sports-lib'; -import { ActivityUtilities } from '@sports-alliance/sports-lib'; import { AppWindowService } from '../../services/app.window.service'; import { LoggerService } from '../../services/logger.service'; +import { + AppEventReprocessService, + ReprocessError, + ReprocessPhase, + ReprocessProgress +} from '../../services/app.event-reprocess.service'; +import { AppProcessingService } from '../../services/app.processing.service'; @Component({ selector: 'app-event-actions', @@ -52,6 +57,8 @@ export class EventActionsComponent implements OnInit, OnDestroy { private auth = inject(Auth); private analyticsService = inject(AppAnalyticsService); private logger = inject(LoggerService); + private eventReprocessService = inject(AppEventReprocessService); + private processingService = inject(AppProcessingService); constructor( @@ -112,12 +119,12 @@ export class EventActionsComponent implements OnInit, OnDestroy { isHydrated() { const activities = this.event.getActivities(); - return activities.length > 0 && activities[0].getAllStreams().length > 0; + return activities.some(activity => activity.getAllStreams().length > 0); } hasDistance() { const activities = this.event.getActivities(); - return activities.length > 0 && activities[0].hasStreamData(DataDistance.type); + return activities.some(activity => activity.hasStreamData(DataDistance.type)); } hasPositionalData() { @@ -135,25 +142,163 @@ export class EventActionsComponent implements OnInit, OnDestroy { }); } + public canReimportFromOriginalFile(): boolean { + const eventAny = this.event as any; + return !!((eventAny.originalFiles && eventAny.originalFiles.length > 0) || + (eventAny.originalFile && eventAny.originalFile.path)); + } + + public getReimportDisabledReason(): string { + if (this.canReimportFromOriginalFile()) { + return ''; + } + return 'No original source files available for this event'; + } + async reGenerateStatistics() { + const confirmed = await this.confirmReprocessAction({ + title: 'Regenerate activity statistics?', + message: 'This will re-calculate statistics like distance, ascent, descent etc...', + confirmLabel: 'Regenerate', + confirmColor: 'primary', + }); + if (!confirmed) { + return; + } + this.snackBar.open('Re-calculating activity statistics', undefined, { duration: 2000, }); - // To use this component we need the full hydrated object and we might not have it - // We attach streams from the original file (if exists) instead of Firestore - await this.eventService.attachStreamsToEventWithActivities(this.user, this.event as any).pipe(take(1)).toPromise(); + const jobId = this.processingService.addJob('process', 'Re-calculating activity statistics...'); + this.processingService.updateJob(jobId, { status: 'processing', progress: 5 }); + + try { + await this.eventReprocessService.regenerateEventStatistics(this.user, this.event as any, { + onProgress: (progress) => this.updateReprocessJob(jobId, progress), + }); + + this.processingService.completeJob(jobId, 'Activity and event statistics recalculated'); + this.snackBar.open('Activity and event statistics have been recalculated', undefined, { + duration: 2000, + }); + this.changeDetectorRef.detectChanges(); + } catch (error) { + this.processingService.failJob(jobId, 'Re-calculation failed'); + this.logger.error('[EventActionsComponent] Failed to re-calculate activity statistics', error); + this.snackBar.open(this.getReprocessErrorMessage(error, 'Could not recalculate statistics.'), undefined, { + duration: 4000, + }); + } + } - this.event.getActivities().forEach(activity => { - activity.clearStats(); - ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); + async reImportActivityFromFile() { + if (!this.canReimportFromOriginalFile()) { + return; + } + + const sourceFilesCount = this.getSourceFilesCount(); + const confirmed = await this.confirmReprocessAction({ + title: 'Reimport activity from file?', + message: sourceFilesCount > 1 + ? `This will download and reparse ${sourceFilesCount} source files and replace current activity data.` + : 'This will download and reparse the original source file and replace current activity data.', + confirmLabel: 'Reimport', + confirmColor: 'primary', }); + if (!confirmed) { + return; + } - EventUtilities.reGenerateStatsForEvent(this.event); - await this.eventService.writeAllEventData(this.user, this.event); - this.snackBar.open('Activity and event statistics have been recalculated', undefined, { + this.snackBar.open('Reimporting activity from source file', undefined, { duration: 2000, }); - this.changeDetectorRef.detectChanges(); + const jobId = this.processingService.addJob('process', 'Reimporting activity from source file...'); + this.processingService.updateJob(jobId, { status: 'processing', progress: 5 }); + + try { + await this.eventReprocessService.reimportEventFromOriginalFiles(this.user, this.event as any, { + onProgress: (progress) => this.updateReprocessJob(jobId, progress), + }); + + this.processingService.completeJob(jobId, 'Activity reimport completed'); + this.snackBar.open('Activity reimported from source file', undefined, { + duration: 2000, + }); + this.changeDetectorRef.detectChanges(); + } catch (error) { + this.processingService.failJob(jobId, 'Reimport failed'); + this.logger.error('[EventActionsComponent] Failed to reimport activity from source file', error); + this.snackBar.open(this.getReprocessErrorMessage(error, 'Could not reimport activity from file.'), undefined, { + duration: 4000, + }); + } + } + + private updateReprocessJob(jobId: string, progress: ReprocessProgress) { + this.processingService.updateJob(jobId, { + status: progress.phase === 'done' ? 'completed' : 'processing', + title: this.getReprocessTitle(progress.phase), + progress: progress.progress, + details: progress.details, + }); + } + + private getReprocessTitle(phase: ReprocessPhase): string { + switch (phase) { + case 'validating': + return 'Validating source files...'; + case 'downloading': + return 'Downloading source files...'; + case 'parsing': + return 'Parsing source files...'; + case 'merging': + return 'Merging parsed activities...'; + case 'regenerating_stats': + return 'Generating statistics...'; + case 'persisting': + return 'Saving event...'; + case 'done': + return 'Done'; + default: + return 'Processing...'; + } + } + + private getReprocessErrorMessage(error: unknown, fallback: string): string { + if (error instanceof ReprocessError) { + if (error.code === 'NO_ORIGINAL_FILES') { + return 'No original source files found for this event.'; + } + if (error.code === 'MULTI_FILE_INCOMPLETE') { + return 'Reimport failed because one or more source files could not be parsed.'; + } + if (error.code === 'PARSE_FAILED') { + return 'Could not parse the original source file.'; + } + if (error.code === 'PERSIST_FAILED') { + return 'Could not save the updated event after reprocessing.'; + } + } + return fallback; + } + + private getSourceFilesCount(): number { + const eventAny = this.event as any; + if (eventAny.originalFiles && eventAny.originalFiles.length > 0) { + return eventAny.originalFiles.length; + } + return eventAny.originalFile?.path ? 1 : 0; + } + + private async confirmReprocessAction(dialogData: ConfirmationDialogData): Promise { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + cancelLabel: 'Cancel', + ...dialogData, + } as ConfirmationDialogData, + }); + const confirmed = await dialogRef.afterClosed().pipe(take(1)).toPromise(); + return confirmed === true; } // downloadEventAsTCX(event: EventInterface) { @@ -170,28 +315,42 @@ export class EventActionsComponent implements OnInit, OnDestroy { // } async downloadJSON() { - const blob = await this.eventService.getEventAsJSONBloB(this.user, this.event as any); - this.fileService.downloadFile( - blob, - this.getFileName(this.event), - new EventExporterJSON().fileExtension, - ); - this.snackBar.open('JSON file served', undefined, { - duration: 2000, - }); + try { + const blob = await this.eventService.getEventAsJSONBloB(this.user, this.event as any); + this.fileService.downloadFile( + blob, + this.getFileName(this.event), + new EventExporterJSON().fileExtension, + ); + this.snackBar.open('JSON file served', undefined, { + duration: 2000, + }); + } catch (error) { + this.logger.error('[EventActionsComponent] Failed to download JSON from original files', error); + this.snackBar.open('Could not download JSON file', undefined, { + duration: 3000, + }); + } } async downloadGPX() { - const blob = await this.eventService.getEventAsGPXBloB(this.user, this.event as any); - this.fileService.downloadFile( - blob, - this.getFileName(this.event), - new EventExporterGPX().fileExtension, - ); - this.analyticsService.logEvent('downloaded_gpx_file'); - this.snackBar.open('GPX file served', undefined, { - duration: 2000, - }); + try { + const blob = await this.eventService.getEventAsGPXBloB(this.user, this.event as any); + this.fileService.downloadFile( + blob, + this.getFileName(this.event), + new EventExporterGPX().fileExtension, + ); + this.analyticsService.logEvent('downloaded_gpx_file'); + this.snackBar.open('GPX file served', undefined, { + duration: 2000, + }); + } catch (error) { + this.logger.error('[EventActionsComponent] Failed to download GPX from original files', error); + this.snackBar.open('Could not download GPX file', undefined, { + duration: 3000, + }); + } } @@ -249,7 +408,15 @@ export class EventActionsComponent implements OnInit, OnDestroy { async delete() { - const dialogRef = this.dialog.open(DeleteConfirmationComponent); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Are you sure you want to delete?', + message: 'All data will be permanently deleted. This operation cannot be undone.', + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + confirmColor: 'warn', + } as ConfirmationDialogData, + }); this.deleteConfirmationSubscription = dialogRef.afterClosed().subscribe(async (result) => { if (!result) { return; diff --git a/src/app/components/event-search/event-search.component.html b/src/app/components/event-search/event-search.component.html index 12567b020..cc3f810c7 100644 --- a/src/app/components/event-search/event-search.component.html +++ b/src/app/components/event-search/event-search.component.html @@ -1,118 +1,119 @@ @if (searchFormGroup) {
- -
- @if (searchFormGroup) { - - Select date range - - - - - - - @if (!startDateControl.hasError('matStartDateInvalid') && - !endDateControl.hasError('matStartDateInvalid') && selectedDateRange === dateRanges.custom) { - Select a date range + +
+ @if (searchFormGroup) { + + Select date range + + + + + + + @if (!startDateControl.hasError('matStartDateInvalid') && + !endDateControl.hasError('matStartDateInvalid') && selectedDateRange === dateRanges.custom) { + Select a date range + } + @if (startDateControl.hasError('matStartDateInvalid') || + endDateControl.hasError('matStartDateInvalid')) { + Invalid date range + } + } - @if (startDateControl.hasError('matStartDateInvalid') || - endDateControl.hasError('matStartDateInvalid')) { - Invalid date range + @if (showActivityTypePicker) { + } - - } - @if (showActivityTypePicker) { - - } - - - - - - -
-
- - @if (dateRangesToShow.indexOf(dateRanges.thisWeek) !== -1) { - - This week - - } - @if (dateRangesToShow.indexOf(dateRanges.lastWeek) !== -1) { - - Last week - - } - @if (dateRangesToShow.indexOf(dateRanges.lastSevenDays) !== -1) { - - 7 days - - } - - - @if (dateRangesToShow.indexOf(dateRanges.thisMonth) !== -1) { - - This month - - } - @if (dateRangesToShow.indexOf(dateRanges.lastMonth) !== -1) { - - Last month - - } - @if (dateRangesToShow.indexOf(dateRanges.lastThirtyDays) !== -1) { - - 30 days - - } - - - @if (dateRangesToShow.indexOf(dateRanges.thisYear) !== -1) { - - {{currentYear}} - - } - @if (dateRangesToShow.indexOf(dateRanges.lastYear) !== -1) { - - {{currentYear - 1}} - - } - - @if (dateRangesToShow.indexOf(dateRanges.all) !== -1 || dateRangesToShow.indexOf(dateRanges.custom) !== -1) { - - @if (dateRangesToShow.indexOf(dateRanges.all) !== -1) { - - All - - } - @if (dateRangesToShow.indexOf(dateRanges.custom) !== -1) { - - Custom - - } - - } - @if (showMergedEventsToggle) { -
- - Merged + + + + + + +
+
+ + @if (dateRangesToShow.indexOf(dateRanges.thisWeek) !== -1) { + + This week + + } + @if (dateRangesToShow.indexOf(dateRanges.lastWeek) !== -1) { + + Last week + + } + @if (dateRangesToShow.indexOf(dateRanges.lastSevenDays) !== -1) { + + 7 days + + } + + + @if (dateRangesToShow.indexOf(dateRanges.thisMonth) !== -1) { + + This month + + } + @if (dateRangesToShow.indexOf(dateRanges.lastMonth) !== -1) { + + Last month + + } + @if (dateRangesToShow.indexOf(dateRanges.lastThirtyDays) !== -1) { + + 30 days + + } - @if (mergedEventsToggleDisabled && !includeMergedEvents) { - {{ mergedEventsToggleHint }} + + @if (dateRangesToShow.indexOf(dateRanges.thisYear) !== -1) { + + {{currentYear}} + + } + @if (dateRangesToShow.indexOf(dateRanges.lastYear) !== -1) { + + {{currentYear - 1}} + + } + + @if (dateRangesToShow.indexOf(dateRanges.all) !== -1 || dateRangesToShow.indexOf(dateRanges.custom) !== -1) { + + @if (dateRangesToShow.indexOf(dateRanges.all) !== -1) { + + All + + } + @if (dateRangesToShow.indexOf(dateRanges.custom) !== -1) { + + Custom + + } + + } + @if (showMergedEventsToggle) { +
+ + Merged + + @if (mergedEventsToggleDisabled && !includeMergedEvents) { + {{ mergedEventsToggleHint }} + } +
} - - } -
+
+
} diff --git a/src/app/components/event-search/event-search.component.css b/src/app/components/event-search/event-search.component.scss similarity index 76% rename from src/app/components/event-search/event-search.component.css rename to src/app/components/event-search/event-search.component.scss index 673c68cdd..4ea402cbf 100644 --- a/src/app/components/event-search/event-search.component.css +++ b/src/app/components/event-search/event-search.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + mat-icon { vertical-align: middle; } @@ -18,16 +20,11 @@ form { gap: 16px; } -/* Ensure shade doesn't participate in flex flow */ -app-shade { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 10; - pointer-events: none; - /* Let clicks pass through if inactive, or handle in component */ +/* Preserve form spacing after moving loading state into app-loading-overlay */ +app-loading-overlay.search-overlay { + display: flex; + flex-direction: column; + gap: 16px; } /* Ensure Search Button aligns well */ @@ -52,7 +49,7 @@ mat-button-toggle-group { background: var(--mat-app-surface-container-low) !important; border-radius: 16px !important; padding: 4px !important; - overflow: hidden; + /* overflow: hidden; Removed to allow scale transform to be fully visible */ height: auto !important; gap: 4px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); @@ -78,12 +75,29 @@ mat-button-toggle:hover:not(.mat-button-toggle-checked) { color: var(--mat-sys-primary) !important; } +/* Ensure internal button is transparent so host background shows through */ +::ng-deep mat-button-toggle .mat-button-toggle-button { + background-color: transparent !important; +} + +/* Keep ripple/state layers rounded to match our custom toggle radius. */ +::ng-deep mat-button-toggle .mat-button-toggle-ripple { + border-radius: inherit !important; + overflow: hidden; +} + +::ng-deep mat-button-toggle .mat-ripple-element, +::ng-deep mat-button-toggle .mat-button-toggle-focus-overlay { + border-radius: inherit !important; +} + /* Premium Active State */ mat-button-toggle.mat-button-toggle-checked { - background: var(--mat-sys-primary) !important; + background-color: var(--mat-sys-primary) !important; color: var(--mat-sys-on-primary) !important; box-shadow: 0 4px 12px rgba(var(--mat-sys-primary-rgb), 0.3) !important; transform: scale(1.02); + z-index: 1; /* Subtle pop for the active item */ } @@ -106,6 +120,7 @@ mat-button-toggle:active { align-items: center; flex-wrap: wrap; gap: 16px; + margin-bottom: 16px; } .merge-toggle-group { @@ -120,7 +135,7 @@ mat-button-toggle:active { } /* Tablet: Reduce gap and padding to prevent overflow */ -@media (max-width: 959px) { +@include bp.handset-or-tablet-portrait { .button-groups { gap: 12px; } @@ -132,7 +147,7 @@ mat-button-toggle:active { } /* Mobile: Ensure filters stay on one row or wrap gracefully */ -@media (max-width: 599.98px) { +@include bp.xsmall { .filters-row { flex-direction: column; align-items: stretch; @@ -161,4 +176,4 @@ mat-button-toggle:active { .merge-toggle-group { width: 100%; } -} \ No newline at end of file +} diff --git a/src/app/components/event-search/event-search.component.ts b/src/app/components/event-search/event-search.component.ts index 1822c0084..e6e638bb6 100644 --- a/src/app/components/event-search/event-search.component.ts +++ b/src/app/components/event-search/event-search.component.ts @@ -22,7 +22,7 @@ import dayjs from 'dayjs'; @Component({ selector: 'app-event-search', templateUrl: './event-search.component.html', - styleUrls: ['./event-search.component.css'], + styleUrls: ['./event-search.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) @@ -123,8 +123,8 @@ export class EventSearchComponent extends LoadingAbstractDirective implements On endDate = (endDate as any).toDate(); } - this.selectedStartDate = startDate ? new Date(new Date(startDate).setHours(0, 0, 0)) : (null as any); - this.selectedEndDate = endDate ? new Date(new Date(endDate).setHours(23, 59, 59)) : (null as any); + this.selectedStartDate = startDate ? new Date(new Date(startDate).setHours(0, 0, 0, 0)) : (null as any); + this.selectedEndDate = endDate ? new Date(new Date(endDate).setHours(23, 59, 59, 999)) : (null as any); this.searchChange.emit({ searchTerm: this.searchFormGroup.get('search')?.value, @@ -144,8 +144,10 @@ export class EventSearchComponent extends LoadingAbstractDirective implements On } dateToggleChange(event: MatButtonToggleChange) { - this.startDateControl?.setValue(getDatesForDateRange(event.source.value, this.startOfTheWeek).startDate); - this.endDateControl?.setValue(getDatesForDateRange(event.source.value, this.startOfTheWeek).endDate); + const nextRange = event.source.value; + const computedRange = getDatesForDateRange(nextRange, this.startOfTheWeek); + this.startDateControl?.setValue(computedRange.startDate); + this.endDateControl?.setValue(computedRange.endDate); this.selectedDateRange = event.source.value; return this.search(); } diff --git a/src/app/components/event-summary/event-summary.component.html b/src/app/components/event-summary/event-summary.component.html index 2028f35df..dd317be34 100644 --- a/src/app/components/event-summary/event-summary.component.html +++ b/src/app/components/event-summary/event-summary.component.html @@ -19,8 +19,10 @@
{{ mainActivityType }} - @if (feelingEmoji) { - {{ feelingEmoji }} + @if (feelingIcon) { + + + } @if (rpe) { RPE {{ rpe }} @@ -31,7 +33,7 @@
- @for (stat of getHeroStats(); track stat) { + @for (stat of heroStats; track stat) {
{{ getStatValue(stat) }} {{ getStatUnit(stat) }} @@ -42,24 +44,39 @@
@if (isOwner) { - - @if (event.isMerge && event.getActivities().length >= 2) { - - } - +
+ + @if (event.isMerge && eventActivitiesCount >= 2) { + + } + +
} + +
+ @if (hasDevices) { + + } + +
- @if (event.getActivities().length > 1) { + @if (eventActivitiesCount > 1) {
@@ -68,31 +85,10 @@
-
- - @if (hasDevices) { -
-
- sensors -
-
-
Sensors
-
View
-
-
- } -
-
- keyboard_double_arrow_down -
-
-
Show
-
More
-
-
-
+
+
+ +
diff --git a/src/app/components/event-summary/event-summary.component.css b/src/app/components/event-summary/event-summary.component.scss similarity index 65% rename from src/app/components/event-summary/event-summary.component.css rename to src/app/components/event-summary/event-summary.component.scss index 746a60610..c6c8e6693 100644 --- a/src/app/components/event-summary/event-summary.component.css +++ b/src/app/components/event-summary/event-summary.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + :host { display: block; margin-bottom: 24px; @@ -129,11 +131,20 @@ .feeling-chip { background: var(--mat-app-surface-container-low); - font-size: 0.9rem; - padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; cursor: help; } +.feeling-chip mat-icon { + width: 18px; + height: 18px; + line-height: 18px; + font-size: 18px; +} + .rpe-chip { background: var(--mat-app-tertiary-container); color: var(--mat-app-on-tertiary-container); @@ -181,22 +192,54 @@ /* Actions */ .summary-actions { display: flex; - align-items: center; - gap: 4px; + flex-direction: column; + align-items: flex-end; + gap: 1px; flex: 0 0 auto; margin-left: auto; } +.summary-actions-primary, +.summary-actions-secondary { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.summary-actions ::ng-deep .mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 36px; + width: 36px; + height: 36px; + padding: 6px; +} + +.summary-actions ::ng-deep .mat-mdc-icon-button .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; +} + .summary-stats-area { display: flex; flex-direction: column; - padding: 24px 40px; + padding: 24px 24px; gap: 16px; border-top: 1px solid var(--mat-app-outline-variant); width: 100%; box-sizing: border-box; } +.summary-stats-layout { + display: block; + width: 100%; +} + +.stats-container { + min-width: 0; +} + .benchmark-btn mat-icon.has-result { color: var(--mat-sys-primary); @@ -209,14 +252,25 @@ border-top: 1px solid var(--mat-app-outline-variant); } -@media (max-width: 900px) { +@include bp.xsmall { + .summary-stats-area { + padding: 12px; + } + + .summary-activities-toggles { + padding-left: 12px; + padding-right: 12px; + } +} + +@include bp.max-900 { .hero-metrics { gap: 16px; padding: 0 10px; } } -@media (max-width: 768px) { +@include bp.max-768 { .summary-primary-info { display: flex; flex-direction: column; @@ -228,8 +282,8 @@ .identity-section { gap: 12px; - padding-right: 48px; - /* Room for absolute actions */ + padding-right: 116px; + /* Room for two action rows */ } .activity-icon-container { @@ -269,24 +323,39 @@ .summary-actions { position: absolute; - top: 16px; - right: 16px; - flex-direction: row; - gap: 4px; + top: 12px; + right: 12px; + gap: 1px; margin: 0; } - .summary-actions ::ng-deep .mat-mdc-icon-button { - --mdc-icon-button-state-layer-size: 40px; + .summary-actions-primary ::ng-deep .mat-mdc-icon-button, + .summary-actions-secondary ::ng-deep .mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 32px; + width: 32px; + height: 32px; + padding: 4px; + } + + .summary-actions-primary ::ng-deep .mat-mdc-icon-button .mat-icon, + .summary-actions-secondary ::ng-deep .mat-mdc-icon-button .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; } } -@media (max-width: 480px) { +@include bp.max-480 { .summary-primary-info { padding: 12px; gap: 12px; } + .identity-section { + padding-right: 104px; + } + .hero-metrics { padding: 12px; gap: 16px; @@ -301,104 +370,10 @@ } } - -/* Stats Grid Action Buttons */ -.devices-item, -.show-more-item { - display: flex !important; - /* Ensure grouping container is flex row */ - flex-direction: row !important; - align-items: center; - gap: 12px; - /* Match standard grid gap */ - cursor: pointer; - background: var(--mat-app-surface-container-high); - border-radius: 12px; - transition: all 0.2s ease; - min-height: 100% !important; - /* Stretch to fill the grid cell */ - /* Allow natural height, preventing row expansion */ - /* Prevent stretching to fill grid cell height */ - justify-content: flex-start; - /* Align left like standard grid items */ - padding: 8px 12px; - /* Add some padding */ - box-sizing: border-box; -} - -.devices-item:hover, -.show-more-item:hover { - background: var(--mat-app-surface-container-highest); - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -} - -.devices-item:active, -.show-more-item:active { - transform: translateY(0); - box-shadow: none; -} - -.devices-item .stat-value-grid, -.show-more-item .stat-value-grid { - color: var(--mat-app-primary); - font-weight: 500; - font-size: 1.1rem; - font-family: 'Barlow Condensed', sans-serif; -} - -.devices-item .stat-avatar mat-icon, -.show-more-item .stat-avatar mat-icon { - color: var(--mat-app-primary); -} - -.devices-item .stat-content, -.show-more-item .stat-content { - display: flex !important; - flex-direction: row !important; - align-items: center; - gap: 6px; -} - -/* Replicating HeaderStats styles for projected content */ -.devices-item .stat-avatar, -.show-more-item .stat-avatar { - flex-shrink: 0; - display: flex !important; - align-items: center; - justify-content: center; -} - -.devices-item .stat-content, -.show-more-item .stat-content { - display: flex !important; - flex-direction: column !important; - /* Standard stack: Label on top, Value below */ - align-items: flex-start !important; - justify-content: center; - min-width: 0; - gap: 0 !important; - /* Reset any gap */ -} - -.devices-item .stat-label, -.show-more-item .stat-label { - font-size: 0.65rem; - font-weight: 400; - text-transform: uppercase; - letter-spacing: 0.05em; +.summary-stats-action-button { color: var(--mat-app-on-surface-variant); - opacity: 0.8; - line-height: 1.1; - white-space: nowrap; } -.devices-item .stat-value-grid, -.show-more-item .stat-value-grid { - font-size: 1.1rem; - font-weight: 400; - color: var(--mat-app-on-surface); - line-height: 1.2; - white-space: nowrap; - font-family: 'Barlow Condensed', sans-serif; +.summary-stats-action-button:hover { + color: var(--mat-app-primary); } diff --git a/src/app/components/event-summary/event-summary.component.spec.ts b/src/app/components/event-summary/event-summary.component.spec.ts index c4082b605..5775c517a 100644 --- a/src/app/components/event-summary/event-summary.component.spec.ts +++ b/src/app/components/event-summary/event-summary.component.spec.ts @@ -3,10 +3,10 @@ import { EventSummaryComponent } from './event-summary.component'; import { AppEventService } from '../../services/app.event.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { EventInterface, User, Privacy, ActivityTypes } from '@sports-alliance/sports-lib'; -import { of } from 'rxjs'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { EventInterface, User, Privacy, ActivityTypes, DataFeeling, Feelings } from '@sports-alliance/sports-lib'; import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { AppBenchmarkFlowService } from '../../services/app.benchmark-flow.service'; @@ -15,6 +15,7 @@ describe('EventSummaryComponent', () => { let fixture: ComponentFixture; let mockEventService: any; let mockBottomSheet: any; + let mockBenchmarkFlowService: any; const mockUser: User = { uid: 'test-user-id', @@ -37,6 +38,12 @@ describe('EventSummaryComponent', () => { open: vi.fn(), }; + mockBenchmarkFlowService = { + openBenchmarkSelectionDialog: vi.fn(), + generateAndOpenReport: vi.fn().mockResolvedValue(undefined), + openBenchmarkReport: vi.fn(), + }; + await TestBed.configureTestingModule({ declarations: [ EventSummaryComponent @@ -46,6 +53,7 @@ describe('EventSummaryComponent', () => { { provide: MatBottomSheet, useValue: mockBottomSheet }, { provide: MatSnackBar, useValue: { open: vi.fn() } }, { provide: ChangeDetectorRef, useValue: { markForCheck: vi.fn() } }, + { provide: AppBenchmarkFlowService, useValue: mockBenchmarkFlowService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -112,5 +120,53 @@ describe('EventSummaryComponent', () => { expect(stats.length).toBe(2); }); }); -}); + describe('summary actions placement', () => { + it('should render show more action in summary actions area outside the stats grid component', () => { + const outsideAction = fixture.nativeElement.querySelector('.summary-actions .show-more-button'); + const insideGridAction = fixture.nativeElement.querySelector('app-event-card-stats-grid .show-more-button'); + + expect(outsideAction).toBeTruthy(); + expect(insideGridAction).toBeFalsy(); + }); + + it('should render sensors action in summary actions area when devices exist', () => { + const devicesFixture = TestBed.createComponent(EventSummaryComponent); + const devicesComponent = devicesFixture.componentInstance; + + devicesComponent.event = mockEvent; + devicesComponent.user = mockUser; + devicesComponent.selectedActivities = [ + { + creator: { + devices: [{ name: 'HRM' }] + } + } as any + ]; + + devicesFixture.detectChanges(); + + const sensorsAction = devicesFixture.nativeElement.querySelector('.summary-actions .devices-button'); + expect(devicesComponent.hasDevices).toBe(true); + expect(sensorsAction).toBeTruthy(); + }); + }); + + describe('feeling icon', () => { + it('should render material symbol for feeling when present', () => { + component.event = { + ...mockEvent, + getStat: (type: string) => { + if (type === DataFeeling.type) { + return { getValue: () => Feelings.Excellent } as any; + } + return null; + } + } as any; + + fixture.detectChanges(); + + expect(component.feelingIcon).toBe('sentiment_very_satisfied'); + }); + }); +}); diff --git a/src/app/components/event-summary/event-summary.component.ts b/src/app/components/event-summary/event-summary.component.ts index 548385b53..2e6c09e41 100644 --- a/src/app/components/event-summary/event-summary.component.ts +++ b/src/app/components/event-summary/event-summary.component.ts @@ -1,7 +1,6 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges, ChangeDetectorRef, computed } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges, ChangeDetectorRef } from '@angular/core'; import { AppEventInterface, BenchmarkOptions, getBenchmarkPairKey } from '../../../../functions/src/shared/app-event.interface'; import { - EventInterface, User, ActivityInterface, UserUnitSettingsInterface, @@ -17,7 +16,6 @@ import { ActivityTypes, ActivityTypesHelper, ActivityTypeGroups, - ServiceNames, } from '@sports-alliance/sports-lib'; import { AppEventService } from '../../services/app.event.service'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; @@ -29,7 +27,7 @@ import { AppBenchmarkFlowService } from '../../services/app.benchmark-flow.servi @Component({ selector: 'app-event-summary', templateUrl: './event-summary.component.html', - styleUrls: ['./event-summary.component.css'], + styleUrls: ['./event-summary.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) @@ -47,6 +45,21 @@ export class EventSummaryComponent implements OnChanges { // Local state for on-demand generated benchmark benchmarkResult: import('../../../../functions/src/shared/app-event.interface').BenchmarkResult | null = null; + private heroStatLookup = new Map(); + private hasDevicesValue = false; + private heroStatsValue: string[] = []; + private eventActivitiesCountValue = 0; + private mainActivityTypeValue = 'Other'; + private benchmarkCountValue = 0; + private feelingValue: Feelings | null = null; + private feelingLabelValue = ''; + private rpeValue: RPEBorgCR10SCale | null = null; + private rpeLabelValue = ''; + private feelingIconValue = ''; + private cachedEventRef: AppEventInterface | null = null; + private cachedSelectedActivitiesRef: ActivityInterface[] | null = null; + private templateStateInitialized = false; + constructor( private eventService: AppEventService, private cd: ChangeDetectorRef, @@ -56,6 +69,9 @@ export class EventSummaryComponent implements OnChanges { } ngOnChanges(changes: SimpleChanges): void { + if (changes['event'] || changes['selectedActivities']) { + this.rebuildTemplateState(); + } } async toggleEventPrivacy() { @@ -87,9 +103,18 @@ export class EventSummaryComponent implements OnChanges { } get hasDevices(): boolean { - return this.selectedActivities?.some(a => - a.creator?.devices?.some(d => d.name || d.manufacturer) - ) ?? false; + this.ensureTemplateState(); + return this.hasDevicesValue; + } + + get heroStats(): string[] { + this.ensureTemplateState(); + return this.heroStatsValue; + } + + get eventActivitiesCount(): number { + this.ensureTemplateState(); + return this.eventActivitiesCountValue; } openDevices() { @@ -129,6 +154,7 @@ export class EventSummaryComponent implements OnChanges { initialSelection: this.selectedActivities, onResult: (result) => { this.benchmarkResult = result; + this.rebuildTemplateState(); this.cd.detectChanges(); } }); @@ -144,6 +170,7 @@ export class EventSummaryComponent implements OnChanges { initialSelection: this.selectedActivities, onResult: (result) => { this.benchmarkResult = result; + this.rebuildTemplateState(); this.cd.detectChanges(); } }); @@ -158,90 +185,145 @@ export class EventSummaryComponent implements OnChanges { initialSelection: this.selectedActivities, onResult: (result) => { this.benchmarkResult = result; + this.rebuildTemplateState(); this.cd.detectChanges(); } }); } get mainActivityType(): string { - return this.event?.getActivities()[0]?.type || 'Other'; + this.ensureTemplateState(); + return this.mainActivityTypeValue; } get benchmarkCount(): number { - if (!this.event?.benchmarkResults) return 0; - return Object.keys(this.event.benchmarkResults).length; + this.ensureTemplateState(); + return this.benchmarkCountValue; } getHeroStats(): string[] { - const type = this.mainActivityType; - if (type === 'Virtual Cycling' || type === 'VirtualRide') { - return [DataDuration.type, DataPowerAvg.type]; - } - const activityTypeEnum = ActivityTypes[type as keyof typeof ActivityTypes] || (Object.values(ActivityTypes).includes(type as ActivityTypes) ? type as ActivityTypes : ActivityTypes.Other); - const group = ActivityTypesHelper.getActivityGroupForActivityType(activityTypeEnum); - - switch (group) { - case ActivityTypeGroups.IndoorSports: - return [DataDuration.type, DataEnergy.type]; - case ActivityTypeGroups.Running: - case ActivityTypeGroups.TrailRunning: - case ActivityTypeGroups.Cycling: - case ActivityTypeGroups.Swimming: - case ActivityTypeGroups.OutdoorAdventures: - case ActivityTypeGroups.WinterSports: - case ActivityTypeGroups.WaterSports: - case ActivityTypeGroups.Performance: - default: - return [DataDistance.type, DataDuration.type]; - } + return this.heroStats; } getStatValue(statType: string): string { + this.ensureTemplateState(); + const cachedStat = this.heroStatLookup.get(statType); + if (cachedStat) { + return cachedStat.value; + } const stat = this.event?.getStat(statType); return stat ? String(stat.getDisplayValue()) : '--'; } getStatUnit(statType: string): string { + this.ensureTemplateState(); + const cachedStat = this.heroStatLookup.get(statType); + if (cachedStat) { + return cachedStat.unit; + } const stat = this.event?.getStat(statType); return stat ? stat.getDisplayUnit() : ''; } get feeling(): Feelings | null { - const stat = this.event?.getStat(DataFeeling.type) as DataFeeling; - return stat ? stat.getValue() as Feelings : null; + this.ensureTemplateState(); + return this.feelingValue; } get feelingLabel(): string { - const f = this.feeling; - if (f === null) return ''; - return Feelings[f] || ''; + this.ensureTemplateState(); + return this.feelingLabelValue; } get rpe(): RPEBorgCR10SCale | null { - const stat = this.event?.getStat(DataRPE.type) as DataRPE; - return stat ? stat.getValue() as RPEBorgCR10SCale : null; + this.ensureTemplateState(); + return this.rpeValue; } get rpeLabel(): string { - const r = this.rpe; - if (r === null) return ''; - return RPEBorgCR10SCale[r] || ''; - } - - get feelingEmoji(): string { - const f = this.feeling; - if (f === null) return ''; - const emojiMap: { [key: number]: string } = { - [Feelings.Excellent]: '🤩', - [Feelings['Very Good']]: '😊', - [Feelings.Good]: '😌', - [Feelings.Average]: '😐', - [Feelings.Poor]: '😕', - }; - return emojiMap[f] || ''; + this.ensureTemplateState(); + return this.rpeLabelValue; + } + + get feelingIcon(): string { + this.ensureTemplateState(); + return this.feelingIconValue; + } + + private rebuildTemplateState(): void { + const activities = this.event?.getActivities?.() ?? []; + this.eventActivitiesCountValue = activities.length; + this.mainActivityTypeValue = activities[0]?.type || 'Other'; + this.heroStatsValue = this.resolveHeroStats(this.mainActivityTypeValue); + this.heroStatLookup = this.buildHeroStatLookup(); + this.hasDevicesValue = this.selectedActivities?.some(a => + a.creator?.devices?.some(d => d.name || d.manufacturer) + ) ?? false; + this.benchmarkCountValue = this.event?.benchmarkResults ? Object.keys(this.event.benchmarkResults).length : 0; + + const feelingStat = this.event?.getStat(DataFeeling.type) as DataFeeling; + this.feelingValue = feelingStat ? feelingStat.getValue() as Feelings : null; + this.feelingLabelValue = this.feelingValue === null ? '' : (Feelings[this.feelingValue] || ''); + this.feelingIconValue = this.resolveFeelingIcon(this.feelingValue); + + const rpeStat = this.event?.getStat(DataRPE.type) as DataRPE; + this.rpeValue = rpeStat ? rpeStat.getValue() as RPEBorgCR10SCale : null; + this.rpeLabelValue = this.rpeValue === null ? '' : (RPEBorgCR10SCale[this.rpeValue] || ''); + this.cachedEventRef = this.event; + this.cachedSelectedActivitiesRef = this.selectedActivities; + this.templateStateInitialized = true; } + private resolveHeroStats(type: string): string[] { + if (type === 'Virtual Cycling' || type === 'VirtualRide') { + return [DataDuration.type, DataPowerAvg.type]; + } + const activityTypeEnum = ActivityTypes[type as keyof typeof ActivityTypes] || (Object.values(ActivityTypes).includes(type as ActivityTypes) ? type as ActivityTypes : ActivityTypes.Other); + const group = ActivityTypesHelper.getActivityGroupForActivityType(activityTypeEnum); + + switch (group) { + case ActivityTypeGroups.IndoorSports: + return [DataDuration.type, DataEnergy.type]; + case ActivityTypeGroups.Running: + case ActivityTypeGroups.TrailRunning: + case ActivityTypeGroups.Cycling: + case ActivityTypeGroups.Swimming: + case ActivityTypeGroups.OutdoorAdventures: + case ActivityTypeGroups.WinterSports: + case ActivityTypeGroups.WaterSports: + case ActivityTypeGroups.Performance: + default: + return [DataDistance.type, DataDuration.type]; + } + } + private buildHeroStatLookup(): Map { + const lookup = new Map(); + this.heroStatsValue.forEach((statType) => { + const stat = this.event?.getStat(statType); + lookup.set(statType, { + value: stat ? String(stat.getDisplayValue()) : '--', + unit: stat ? stat.getDisplayUnit() : '', + }); + }); + return lookup; + } + private resolveFeelingIcon(feeling: Feelings | null): string { + if (feeling === null) return ''; + const iconMap: { [key: number]: string } = { + [Feelings.Excellent]: 'sentiment_very_satisfied', + [Feelings['Very Good']]: 'sentiment_satisfied', + [Feelings.Good]: 'mood', + [Feelings.Average]: 'sentiment_neutral', + [Feelings.Poor]: 'sentiment_dissatisfied', + }; + return iconMap[feeling] || ''; + } + private ensureTemplateState(): void { + if (!this.templateStateInitialized || this.cachedEventRef !== this.event || this.cachedSelectedActivitiesRef !== this.selectedActivities) { + this.rebuildTemplateState(); + } + } } diff --git a/src/app/components/event-table/actions/event.table.actions.component.html b/src/app/components/event-table/actions/event.table.actions.component.html index 6723ab326..e9f12bac1 100644 --- a/src/app/components/event-table/actions/event.table.actions.component.html +++ b/src/app/components/event-table/actions/event.table.actions.component.html @@ -2,7 +2,7 @@ (click)="$event.preventDefault(); $event.stopPropagation();"> more_vert - + @for (dataType of dataTypes; track dataType) { diff --git a/src/app/components/event-table/event.table.component.html b/src/app/components/event-table/event.table.component.html index 4bcac9c31..97193aa26 100644 --- a/src/app/components/event-table/event.table.component.html +++ b/src/app/components/event-table/event.table.component.html @@ -42,94 +42,103 @@
-
- - @for (column of getColumnsToDisplay(); track column; let isFirst = $first; let isLast = $last; let i = $index) { - - @if (isFirst && showActions) { - - } - @if (isLast && showActions) { - - } - @if ((!(isFirst && showActions) && !(isLast && showActions))) { - - } - + + - + + + + +
- - - - - - - - @if (column === 'Checkbox') { - - + +
+ + @for (column of displayedColumns; track column; let isFirst = $first; let isLast = $last) { + + @if (isFirst && showActions) { + } - @if (column === 'Actions') { - - @if (user) { - - } - + @if (isLast && showActions) { + + } + @if ((!(isFirst && showActions) && !(isLast && showActions))) { + } - @if (column === 'Activity Types') { - - @if (row['Merged Event']) { - - analytics - + - - } - + + + } - - - - - - - -
+ + + + + + + + @if (column === 'Checkbox') { + + } - @if (!row['Merged Event'] && row[column]) { - + @if (column === 'Actions') { + + @if (user) { + + } + } - {{ row[column] }} - - } - @if (column !== 'Checkbox' && column !== 'Actions' && column !== 'Activity Types' && column!=='Privacy') { - - {{ row[column] }} - @if (column === 'Ascent' && row.isAscentExcluded) { - info + @if (column === 'Activity Types') { + + @if (row['Merged Event']) { + + analytics + + } + @if (!row['Merged Event'] && row[column]) { + + } + {{ row[column] }} + } - @if (column === 'Descent' && row.isDescentExcluded) { - info + @if (column !== 'Checkbox' && column !== 'Actions' && column !== 'Activity Types' && column!=='Privacy') { + + @if (column === 'Description') { + {{ row[column] }} + } + @if (column === 'Device Names') { + {{ row[column] }} + } + @if (column !== 'Description' && column !== 'Device Names') { + {{ row[column] }} + } + @if (column === 'Ascent' && row.isAscentExcluded) { + info + } + @if (column === 'Descent' && row.isDescentExcluded) { + info + } + } - - } -
- No events found matching "{{searchTerm}}" - No events found -
-
+
+ No events found matching "{{searchTerm}}" + No events found +
+
- + +
diff --git a/src/app/components/event-table/event.table.component.css b/src/app/components/event-table/event.table.component.scss similarity index 64% rename from src/app/components/event-table/event.table.component.css rename to src/app/components/event-table/event.table.component.scss index 6328bbed0..60ff13f75 100644 --- a/src/app/components/event-table/event.table.component.css +++ b/src/app/components/event-table/event.table.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + /* Consolidated and cleaned up styles for event table */ /* Keep basic layout but remove fighting against Material */ @@ -32,16 +34,35 @@ } /* Responsive width for the Start Date column */ -.mat-column-Start-Date { - min-width: 90px; +.mat-column-Start-Date, +.cdk-column-Start-Date, +.mat-column-start-date, +.cdk-column-start-date { + min-width: 120px; width: auto; white-space: nowrap; } -@media (min-width: 960px) { - .mat-column-Start-Date { - min-width: 200px; - width: 220px; +@include bp.handset-or-tablet-portrait { + + .mat-column-Start-Date, + .cdk-column-Start-Date, + .mat-column-start-date, + .cdk-column-start-date { + min-width: 60px !important; + width: 84px !important; + max-width: 84px !important; + } +} + +@include bp.medium-and-up { + + .mat-column-Start-Date, + .cdk-column-Start-Date, + .mat-column-start-date, + .cdk-column-start-date { + min-width: 120px !important; + width: 120px !important; } } @@ -61,6 +82,10 @@ letter-spacing: 0.5px; } +.mat-mdc-cell { + font-family: 'Barlow Condensed', sans-serif; +} + .mat-mdc-row { transition: background-color 0.2s; } @@ -95,15 +120,37 @@ opacity: 1; } -/* Specific column adjustments */ -.mat-column-Start-Date { - min-width: 120px; -} - .mat-column-Activity-Types { min-width: 140px; } +/* Truncate only row activity type values (not header) */ +.mat-mdc-cell.mat-column-Activity-Types, +.mat-mdc-cell.cdk-column-Activity-Types, +.mat-mdc-cell.mat-column-activity-types, +.mat-mdc-cell.cdk-column-activity-types { + width: 120px; + max-width: 140px; +} + +/* Truncate only row description values (not header) */ +.mat-mdc-cell.mat-column-Description, +.mat-mdc-cell.cdk-column-Description, +.mat-mdc-cell.mat-column-description, +.mat-mdc-cell.cdk-column-description { + width: 120px; + max-width: 140px; +} + +/* Truncate only row device names values (not header) */ +.mat-mdc-cell.mat-column-Device-Names, +.mat-mdc-cell.cdk-column-Device-Names, +.mat-mdc-cell.mat-column-device-names, +.mat-mdc-cell.cdk-column-device-names { + width: 160px; + max-width: 160px; +} + /* Icons alignment in headers */ ::ng-deep .mat-sort-header-container { display: flex !important; @@ -111,12 +158,15 @@ } /* Mobile Adjustments for Overall Table Compactness */ -@media (max-width: 599.98px) { +@include bp.handset-or-tablet-portrait { .mat-mdc-header-cell, .mat-mdc-cell { - padding: 0 10px !important; + padding: 0 4px !important; } +} + +@include bp.xsmall { .mat-column-Checkbox, .mat-column-Privacy, @@ -145,7 +195,6 @@ mat-icon { border: 1px solid var(--mat-app-outline-variant); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); overflow: hidden; - margin-bottom: 3rem; width: 100%; max-width: 100%; } @@ -192,6 +241,34 @@ mat-paginator { gap: 4px; } +.description-cell { + max-width: 140px; + min-width: 0; +} + +.description-text { + display: block; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-names-cell { + max-width: 160px; + min-width: 0; +} + +.device-names-text { + display: block; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .excluded-icon { font-size: 14px; width: 14px; @@ -204,6 +281,22 @@ mat-paginator { display: flex; align-items: center; gap: 8px; + max-width: 140px; + min-width: 0; +} + +.activity-type-text { + display: block; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-type-cell app-activity-type-icon, +.activity-type-cell .benchmark-icon { + flex: 0 0 auto; } .benchmark-icon { @@ -219,4 +312,4 @@ mat-paginator { .benchmark-icon:hover mat-icon { opacity: 0.85; -} +} \ No newline at end of file diff --git a/src/app/components/event-table/event.table.component.ts b/src/app/components/event-table/event.table.component.ts index 4910a35bd..2b7815d5e 100644 --- a/src/app/components/event-table/event.table.component.ts +++ b/src/app/components/event-table/event.table.component.ts @@ -31,7 +31,7 @@ import { debounceTime, take, map } from 'rxjs/operators'; import { firstValueFrom, race, Subject, Subscription } from 'rxjs'; import { rowsAnimation, expandCollapse } from '../../animations/animations'; import { DataActivityTypes } from '@sports-alliance/sports-lib'; -import { DeleteConfirmationComponent } from '../delete-confirmation/delete-confirmation.component'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; import { AppUserService } from '../../services/app.user.service'; import { AppUserUtilities } from '../../utils/app.user.utilities'; import { AppAnalyticsService } from '../../services/app.analytics.service'; @@ -52,7 +52,7 @@ import { MergeOptionsDialogComponent } from './merge-options-dialog/merge-option @Component({ selector: 'app-event-table', templateUrl: './event.table.component.html', - styleUrls: ['./event.table.component.css'], + styleUrls: ['./event.table.component.scss'], animations: [ rowsAnimation, expandCollapse @@ -75,6 +75,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O selection = new SelectionModel(true, []); selectedColumns = AppUserUtilities.getDefaultSelectedTableColumns(); + displayedColumns: string[] = []; public show = true @@ -82,6 +83,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O private sortSubscription!: Subscription; private breakpointSubscription!: Subscription; private isHandset = false; + private readonly defaultSelectedColumns = AppUserUtilities.getDefaultSelectedTableColumns(); private searchSubject: Subject = new Subject(); @@ -112,11 +114,15 @@ export class EventTableComponent extends DataTableAbstractDirective implements O return; } if (this.events && simpleChanges.events && this.data.paginator && this.data.sort) { // If there is no paginator and sort then the compoenent is not initialized on view - this.processChanges(); + this.processChanges('on_changes_events'); } if (this.user && simpleChanges.user) { this.selectedColumns = this.user.settings?.dashboardSettings?.tableSettings?.selectedColumns || AppUserUtilities.getDefaultSelectedTableColumns(); this.paginator?._changePageSize(this.user.settings?.dashboardSettings?.tableSettings?.eventsPerPage || 10); + this.updateDisplayedColumns(); + } + if (simpleChanges.showActions) { + this.updateDisplayedColumns(); } } @@ -124,6 +130,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O if (!this.user) { throw new Error(`Component needs user`) } + this.updateDisplayedColumns(); this.searchSubject.pipe( debounceTime(250) ).subscribe(searchTextValue => { @@ -133,9 +140,13 @@ export class EventTableComponent extends DataTableAbstractDirective implements O this.breakpointSubscription = this.breakpointObserver .observe([AppBreakpoints.HandsetOrTabletPortrait]) .subscribe(result => { - this.isHandset = result.matches; - if (this.events) { - this.processChanges(); + const nextIsHandset = result.matches; + if (nextIsHandset === this.isHandset) { + return; + } + this.isHandset = nextIsHandset; + if (this.events && this.data.paginator && this.data.sort) { + this.processChanges('breakpoint_change'); } this.changeDetector.markForCheck(); }); @@ -155,7 +166,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O } }); if (this.events) { - this.processChanges(); + this.processChanges('after_view_init'); } } @@ -252,6 +263,11 @@ export class EventTableComponent extends DataTableAbstractDirective implements O } } + trackByEventId = (index: number, row: StatRowElement): string | number => { + const event = row?.Event as EventInterface | undefined; + return event?.getID ? event.getID() : index; + } + isAllSelected() { const numSelected = this.selection.selected.length; const numRows = this.data.data.length; @@ -415,7 +431,15 @@ export class EventTableComponent extends DataTableAbstractDirective implements O async deleteSelection() { this.loading(); - const dialogRef = this.dialog.open(DeleteConfirmationComponent); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Are you sure you want to delete?', + message: 'All data will be permanently deleted. This operation cannot be undone.', + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + confirmColor: 'warn', + } + }); this.deleteConfirmationSubscription = dialogRef.afterClosed().subscribe(async (result) => { if (!result) { this.loaded(); @@ -432,7 +456,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O if (this.events) { const deletedIds = new Set(eventsToDelete.map(e => e.getID())); this.events = this.events.filter(e => !deletedIds.has(e.getID())); - this.processChanges(); + this.processChanges('after_delete_selection'); } this.analyticsService.logEvent('delete_events'); @@ -584,25 +608,25 @@ export class EventTableComponent extends DataTableAbstractDirective implements O // Todo cache this please getColumnsToDisplay() { - // push all the rest - let columns = [ + return this.displayedColumns; + } + + private updateDisplayedColumns() { + const sortedSelectedColumns = (this.selectedColumns || []) + .filter(column => column !== 'Description') + .sort((a, b) => this.defaultSelectedColumns.indexOf(a) - this.defaultSelectedColumns.indexOf(b)); + + const columns = [ 'Checkbox', 'Start Date', - ...(this.selectedColumns || []) - .filter(column => column !== 'Description') - .sort(function (a, b) { - const defaultColumns = AppUserUtilities.getDefaultSelectedTableColumns(); - return defaultColumns.indexOf(a) - defaultColumns.indexOf(b); - }), + ...sortedSelectedColumns, 'Description', - 'Actions' - ] + 'Actions', + ]; - if (!this.showActions) { - columns = columns.filter(column => column !== 'Checkbox' && column !== 'Actions'); - } - - return columns + this.displayedColumns = this.showActions + ? columns + : columns.filter(column => column !== 'Checkbox' && column !== 'Actions'); } async saveEventDescription(description: string, event: EventInterface) { @@ -654,6 +678,7 @@ export class EventTableComponent extends DataTableAbstractDirective implements O async selectedColumnsChange(event: string[]) { this.selectedColumns = event + this.updateDisplayedColumns(); this.user.settings.dashboardSettings.tableSettings.selectedColumns = this.selectedColumns await this.userService.updateUserProperties(this.user, { settings: this.user.settings }) } @@ -673,46 +698,57 @@ export class EventTableComponent extends DataTableAbstractDirective implements O return false } - private processChanges() { - if (!this.events) { + private processChanges(trigger: string = 'unknown') { + if (!this.events || !this.user) { return; } + const processStart = performance.now(); + const dateFormat = this.isHandset ? 'd MMM yy' : 'EEEEEE d MMM yy HH:mm'; + const removedAscentTypes = new Set((this.user.settings.summariesSettings?.removeAscentForEventTypes || []) as ActivityTypes[]); + const removedDescentTypes = new Set((((this.user.settings.summariesSettings as any)?.removeDescentForEventTypes || [])) as ActivityTypes[]); + const rows: StatRowElement[] = []; this.selection.clear(); - // this.data = new MatTableDataSource(data); - this.data.data = this.events.reduce((EventRowElementsArray, event) => { + for (const event of this.events) { if (!event) { - return EventRowElementsArray; + continue; } - const statRowElement = this.getStatsRowElement(event.getStatsAsArray(), (event.getStat(DataActivityTypes.type)) ? (event.getStat(DataActivityTypes.type)).getValue() : [ActivityTypes.unknown], this.user.settings.unitSettings, event.isMerge); + const activityTypesStat = event.getStat(DataActivityTypes.type); + const statRowElement = this.getStatsRowElement( + event.getStatsAsArray(), + activityTypesStat ? activityTypesStat.getValue() : [ActivityTypes.unknown], + this.user.settings.unitSettings, + event.isMerge + ); + const activityTypes = event.getActivityTypesAsArray(); + const primaryActivityType = activityTypes.length > 1 + ? ActivityTypes.Multisport + : (ActivityTypes[activityTypes[0] as keyof typeof ActivityTypes] || ActivityTypes.unknown); statRowElement['Privacy'] = event.privacy; statRowElement['Name'] = event.name; - const dateFormat = this.isHandset ? 'd MMM yy' : 'EEEEEE d MMM yy HH:mm'; statRowElement['Start Date'] = (event.startDate instanceof Date && !isNaN(+event.startDate)) ? this.datePipe.transform(event.startDate, dateFormat) : 'None?'; statRowElement['Activity Types'] = event.getActivityTypesAsString(); statRowElement['Merged Event'] = event.isMerge; statRowElement['Description'] = event.description; statRowElement['Device Names'] = event.getDeviceNamesAsString(); statRowElement['Color'] = this.eventColorService.getColorForActivityTypeByActivityTypeGroup( - event.getActivityTypesAsArray().length > 1 ? ActivityTypes.Multisport : ActivityTypes[event.getActivityTypesAsArray()[0] as keyof typeof ActivityTypes] + primaryActivityType ); statRowElement['Gradient'] = this.eventColorService.getGradientForActivityTypeGroup( - event.getActivityTypesAsArray().length > 1 ? ActivityTypes.Multisport : ActivityTypes[event.getActivityTypesAsArray()[0] as keyof typeof ActivityTypes] + primaryActivityType ); statRowElement['Event'] = event; - const activityTypes = event.getActivityTypesAsArray(); - statRowElement.isAscentExcluded = activityTypes.some(type => AppEventUtilities.shouldExcludeAscent(type as ActivityTypes) || - (this.user.settings.summariesSettings?.removeAscentForEventTypes || []).includes(type as any) + removedAscentTypes.has(type as any) ); statRowElement.isDescentExcluded = activityTypes.some(type => AppEventUtilities.shouldExcludeDescent(type as ActivityTypes) || - ((this.user.settings.summariesSettings as any)?.removeDescentForEventTypes || []).includes(type as any) + removedDescentTypes.has(type as any) ); statRowElement['Has Benchmark'] = (event as any).benchmarkResult || ((event as any).benchmarkResults && Object.keys((event as any).benchmarkResults).length > 0); @@ -723,9 +759,17 @@ export class EventTableComponent extends DataTableAbstractDirective implements O statRowElement['sort.Description'] = statRowElement['Description']; statRowElement['sort.Device Names'] = statRowElement['Device Names']; - EventRowElementsArray.push(statRowElement); - return EventRowElementsArray; - }, []); + rows.push(statRowElement); + } + this.data.data = rows; + this.logger.info('[perf] event_table_process_changes', { + durationMs: Number((performance.now() - processStart).toFixed(2)), + trigger, + inputEvents: this.events.length, + outputRows: this.data.data.length, + isHandset: this.isHandset, + pageSize: this.paginator?.pageSize || this.user.settings?.dashboardSettings?.tableSettings?.eventsPerPage || 0, + }); this.loaded(); } diff --git a/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.css b/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.scss similarity index 92% rename from src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.css rename to src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.scss index af840ebde..871fa2ca6 100644 --- a/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.css +++ b/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/breakpoints' as bp; + .merge-options { display: grid; gap: 16px; @@ -45,7 +47,7 @@ height: 18px; } -@media (max-width: 599.98px) { +@include bp.xsmall { .option-card { padding: 14px; } diff --git a/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.ts b/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.ts index b72f8949f..1e54b2361 100644 --- a/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.ts +++ b/src/app/components/event-table/merge-options-dialog/merge-options-dialog.component.ts @@ -7,7 +7,7 @@ export type MergeOption = 'benchmark' | 'multi'; @Component({ selector: 'app-merge-options-dialog', templateUrl: './merge-options-dialog.component.html', - styleUrls: ['./merge-options-dialog.component.css'], + styleUrls: ['./merge-options-dialog.component.scss'], standalone: false }) export class MergeOptionsDialogComponent { diff --git a/src/app/components/event/activities-toggles/activities-toggles.component.html b/src/app/components/event/activities-toggles/activities-toggles.component.html index d4be098bf..e7fdaa7e9 100644 --- a/src/app/components/event/activities-toggles/activities-toggles.component.html +++ b/src/app/components/event/activities-toggles/activities-toggles.component.html @@ -2,7 +2,8 @@ aria-label="Activity selection"> @for (activity of activities(); track trackByActivityId($index, activity)) {
{{ activity.type }} @@ -11,9 +12,21 @@ activity.getDistance().getDisplayUnit() }} @if (shouldShowDeviceNames()) { - {{ getDeviceName(activity) }} + + {{ getDeviceName(activity) }} + @if (isOwner()) { + + } + }
} - \ No newline at end of file + diff --git a/src/app/components/event/activities-toggles/activities-toggles.component.css b/src/app/components/event/activities-toggles/activities-toggles.component.scss similarity index 73% rename from src/app/components/event/activities-toggles/activities-toggles.component.css rename to src/app/components/event/activities-toggles/activities-toggles.component.scss index e5a876d3a..46ee94834 100644 --- a/src/app/components/event/activities-toggles/activities-toggles.component.css +++ b/src/app/components/event/activities-toggles/activities-toggles.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/breakpoints' as bp; + .activity-chips { display: flex; flex-wrap: wrap; @@ -49,6 +51,15 @@ ::ng-deep .activity-chip .mat-mdc-chip-action-label { padding-left: 0 !important; + width: 100%; + display: flex; +} + +::ng-deep .activity-chip .mat-mdc-chip-action, +::ng-deep .activity-chip .mdc-evolution-chip__action, +::ng-deep .activity-chip .mdc-evolution-chip__action--primary, +::ng-deep .activity-chip .mdc-evolution-chip__cell { + width: 100%; } /* Force transparent chip background across MDC internal elements */ @@ -71,15 +82,26 @@ .activity-chip:not(.mat-mdc-chip-selected) { border: 1px solid var(--mat-app-outline-variant) !important; - opacity: 0.7; + opacity: 1; +} + +.activity-chip:not(.mat-mdc-chip-disabled) { + cursor: pointer; +} + +.activity-chip.mat-mdc-chip-disabled { + opacity: 0.6; + cursor: not-allowed; } .chip-content { display: flex; flex-direction: column; align-items: flex-start; + align-self: stretch; gap: 2px; padding: 4px 0; + width: 100%; } .chip-type { @@ -103,14 +125,43 @@ margin-top: 2px; } +.chip-device-row { + display: flex; + align-items: center; + width: 100%; + gap: 2px; + overflow: visible; +} + +.chip-device-edit-button { + margin-left: auto; + width: 20px; + height: 20px !important; + min-width: 20px; + padding: 0 !important; + line-height: 20px; + --mdc-icon-button-state-layer-size: 20px !important; + --mat-icon-button-state-layer-size: 20px !important; + --mat-icon-button-touch-target-display: none; + overflow: visible; + color: var(--mat-app-on-surface-variant); +} + +.chip-device-edit-button mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + /* Responsive: on handheld widths, let each chip stretch across the row */ -@media (max-width: 600px) { +@include bp.xsmall { .activity-chips { gap: 10px; } + .activity-chip { flex: 1 1 100%; min-width: auto; width: 100%; } -} +} \ No newline at end of file diff --git a/src/app/components/event/activities-toggles/activities-toggles.component.spec.ts b/src/app/components/event/activities-toggles/activities-toggles.component.spec.ts new file mode 100644 index 000000000..dff546c6b --- /dev/null +++ b/src/app/components/event/activities-toggles/activities-toggles.component.spec.ts @@ -0,0 +1,221 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +import { ActivitiesTogglesComponent } from './activities-toggles.component'; +import { AppActivitySelectionService } from '../../../services/activity-selection-service/app-activity-selection.service'; +import { AppEventColorService } from '../../../services/color/app.event.color.service'; +import { MatDialog } from '@angular/material/dialog'; +import { AppEventService } from '../../../services/app.event.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +const createActivity = (id: string, creatorName: string, serialNumber: string, swInfo = ''): any => ({ + getID: () => id, + type: 'Run', + creator: { + name: creatorName, + serialNumber, + swInfo, + }, + getDuration: () => ({ getDisplayValue: () => '1:00:00' }), + getDistance: () => ({ getDisplayValue: () => 10, getDisplayUnit: () => 'km' }), +}); + +describe('ActivitiesTogglesComponent', () => { + let component: ActivitiesTogglesComponent; + let fixture: ComponentFixture; + + let mockDialog: { open: ReturnType }; + let mockEventService: { writeActivityAndEventData: ReturnType }; + let mockSnackBar: { open: ReturnType }; + + const mockSelectionService = { + selectedActivities: { + select: vi.fn(), + deselect: vi.fn(), + }, + }; + + const mockColorService = { + getActivityColor: vi.fn(() => '#ff0000'), + }; + + const user = { uid: 'user-1' } as any; + + beforeEach(async () => { + mockDialog = { open: vi.fn(() => ({ afterClosed: () => of(undefined) })) }; + mockEventService = { + writeActivityAndEventData: vi.fn().mockResolvedValue(undefined), + }; + mockSnackBar = { open: vi.fn() }; + + await TestBed.configureTestingModule({ + declarations: [ActivitiesTogglesComponent], + providers: [ + { provide: AppActivitySelectionService, useValue: mockSelectionService }, + { provide: AppEventColorService, useValue: mockColorService }, + { provide: MatDialog, useValue: mockDialog }, + { provide: AppEventService, useValue: mockEventService }, + { provide: MatSnackBar, useValue: mockSnackBar }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ActivitiesTogglesComponent); + component = fixture.componentInstance; + }); + + const setupInputs = (owner: boolean) => { + const a1 = createActivity('a1', 'Garmin', '111', '21.19'); + const a2 = createActivity('a2', 'Wahoo', '222', '3.1'); + + const event = { + getID: () => 'event-1', + isMerge: true, + getActivities: () => [a1, a2], + addStat: vi.fn(), + } as any; + + fixture.componentRef.setInput('event', event); + fixture.componentRef.setInput('selectedActivities', [a1, a2]); + fixture.componentRef.setInput('isOwner', owner); + fixture.componentRef.setInput('user', user); + fixture.detectChanges(); + + return { event, a1, a2 }; + }; + + it('renders edit button only for owner when device chips are shown', () => { + setupInputs(true); + const ownerButtons = fixture.nativeElement.querySelectorAll('.chip-device-edit-button'); + expect(ownerButtons.length).toBe(2); + + const fixture2 = TestBed.createComponent(ActivitiesTogglesComponent); + const c2 = fixture2.componentInstance; + const a1 = createActivity('a1', 'Garmin', '111'); + const a2 = createActivity('a2', 'Wahoo', '222'); + const event = { getID: () => 'event-2', isMerge: true, getActivities: () => [a1, a2], addStat: vi.fn() } as any; + fixture2.componentRef.setInput('event', event); + fixture2.componentRef.setInput('selectedActivities', [a1, a2]); + fixture2.componentRef.setInput('isOwner', false); + fixture2.componentRef.setInput('user', user); + fixture2.detectChanges(); + + const nonOwnerButtons = fixture2.nativeElement.querySelectorAll('.chip-device-edit-button'); + expect(c2.shouldShowDeviceNames()).toBe(true); + expect(nonOwnerButtons.length).toBe(0); + }); + + it('edit button click does not toggle activity selection', () => { + setupInputs(true); + const toggleSpy = vi.spyOn(component, 'toggleActivity'); + + const firstEditButton = fixture.nativeElement.querySelector('.chip-device-edit-button') as HTMLButtonElement; + firstEditButton.click(); + + expect(toggleSpy).not.toHaveBeenCalled(); + }); + + it('does not deselect when only one activity is selected', () => { + const { a1 } = setupInputs(true); + fixture.componentRef.setInput('selectedActivities', [a1]); + fixture.detectChanges(); + + component.toggleActivity(a1); + + expect(mockSelectionService.selectedActivities.deselect).not.toHaveBeenCalled(); + expect(mockSelectionService.selectedActivities.select).not.toHaveBeenCalled(); + }); + + it('deselects when more than one activity is selected', () => { + const { a1 } = setupInputs(true); + + component.toggleActivity(a1); + + expect(mockSelectionService.selectedActivities.deselect).toHaveBeenCalledWith(a1); + }); + + it('selects activity when it is not selected', () => { + const { a1, a2 } = setupInputs(true); + fixture.componentRef.setInput('selectedActivities', [a1]); + fixture.detectChanges(); + + component.toggleActivity(a2); + + expect(mockSelectionService.selectedActivities.select).toHaveBeenCalledWith(a2); + }); + + it('uses reference equality when activity IDs are missing', () => { + const noIdA = createActivity('', 'Garmin', '111'); + const noIdB = createActivity('', 'Wahoo', '222'); + noIdA.getID = () => undefined; + noIdB.getID = () => undefined; + + const event = { + getID: () => 'event-1', + isMerge: true, + getActivities: () => [noIdA, noIdB], + addStat: vi.fn(), + } as any; + + fixture.componentRef.setInput('event', event); + fixture.componentRef.setInput('selectedActivities', [noIdA]); + fixture.componentRef.setInput('isOwner', true); + fixture.componentRef.setInput('user', user); + fixture.detectChanges(); + + expect(component.isSelected(noIdA)).toBe(true); + expect(component.isSelected(noIdB)).toBe(false); + expect(component.isOnlySelectedActivity(noIdA)).toBe(true); + expect(component.isOnlySelectedActivity(noIdB)).toBe(false); + }); + + it('renameDevice updates only clicked activity and persists event + activity', async () => { + const { event, a1 } = setupInputs(true); + mockDialog.open.mockReturnValue({ afterClosed: () => of('Renamed Device') }); + + await component.renameDevice(a1); + + expect(a1.creator.name).toBe('Renamed Device'); + expect(mockEventService.writeActivityAndEventData).toHaveBeenCalledWith(user, event, a1); + expect(event.addStat).toHaveBeenCalledTimes(1); + }); + + it('rolls back renamed device name when transactional write fails', async () => { + const { a1 } = setupInputs(true); + mockDialog.open.mockReturnValue({ afterClosed: () => of('Renamed Device') }); + mockEventService.writeActivityAndEventData.mockRejectedValueOnce(new Error('transaction write failed')); + + await component.renameDevice(a1); + + expect(a1.creator.name).toBe('Garmin'); + expect(mockEventService.writeActivityAndEventData).toHaveBeenCalledTimes(1); + expect(mockSnackBar.open).toHaveBeenCalledWith('Could not update device name', undefined, { duration: 3500 }); + }); + + it('renameDevice exits safely when activity has no creator metadata', async () => { + const { event, a1 } = setupInputs(true); + a1.creator = undefined; + mockDialog.open.mockReturnValue({ afterClosed: () => of('Renamed Device') }); + + await component.renameDevice(a1); + + expect(mockEventService.writeActivityAndEventData).not.toHaveBeenCalled(); + expect(event.addStat).not.toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith('Could not update device name', undefined, { duration: 3500 }); + }); + + it('renameDevice does nothing when dialog returns cancel/invalid value', async () => { + const { event, a1 } = setupInputs(true); + + mockDialog.open.mockReturnValueOnce({ afterClosed: () => of(undefined) }); + await component.renameDevice(a1); + + mockDialog.open.mockReturnValueOnce({ afterClosed: () => of('') }); + await component.renameDevice(a1); + + expect(mockEventService.writeActivityAndEventData).not.toHaveBeenCalled(); + expect(event.addStat).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/event/activities-toggles/activities-toggles.component.ts b/src/app/components/event/activities-toggles/activities-toggles.component.ts index 55ace111d..bb17e6267 100644 --- a/src/app/components/event/activities-toggles/activities-toggles.component.ts +++ b/src/app/components/event/activities-toggles/activities-toggles.component.ts @@ -1,26 +1,28 @@ import { + ChangeDetectorRef, ChangeDetectionStrategy, Component, computed, - DestroyRef, inject, input, - OnInit } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { debounceTime } from 'rxjs/operators'; -import { EventInterface, ActivityInterface, User } from '@sports-alliance/sports-lib'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DataDeviceNames, EventInterface, ActivityInterface, User } from '@sports-alliance/sports-lib'; import { AppActivitySelectionService } from '../../../services/activity-selection-service/app-activity-selection.service'; import { AppEventColorService } from '../../../services/color/app.event.color.service'; +import { AppEventService } from '../../../services/app.event.service'; +import { DeviceNameEditDialogComponent } from './device-name-edit-dialog/device-name-edit-dialog.component'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'app-activities-toggles', templateUrl: './activities-toggles.component.html', - styleUrls: ['./activities-toggles.component.css'], + styleUrls: ['./activities-toggles.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) -export class ActivitiesTogglesComponent implements OnInit { +export class ActivitiesTogglesComponent { // Signal inputs event = input.required(); selectedActivities = input.required(); @@ -28,7 +30,10 @@ export class ActivitiesTogglesComponent implements OnInit { user = input(); // Injected services - private destroyRef = inject(DestroyRef); + private changeDetectorRef = inject(ChangeDetectorRef); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + private eventService = inject(AppEventService); public activitySelectionService = inject(AppActivitySelectionService); public eventColorService = inject(AppEventColorService); @@ -54,27 +59,107 @@ export class ActivitiesTogglesComponent implements OnInit { return new Set(ids).size > 1; }); - ngOnInit() { - } + // Computed: normalize current selection into fast lookup sets. + selectedState = computed(() => { + const selectedActivities = this.selectedActivities() ?? []; + const selectedIDs = new Set(); + const selectedRefs = new Set(); + + selectedActivities.forEach((selectedActivity) => { + selectedRefs.add(selectedActivity); + const selectedID = selectedActivity?.getID?.(); + if (selectedID) { + selectedIDs.add(selectedID); + } + }); + + return { + selectedIDs, + selectedRefs, + selectedCount: selectedActivities.length, + }; + }); /** * Check if an activity is selected. */ isSelected(activity: ActivityInterface): boolean { - return this.selectedActivities().some(a => a.getID() === activity.getID()); + const state = this.selectedState(); + const activityID = activity?.getID?.(); + if (activityID) { + return state.selectedIDs.has(activityID); + } + return state.selectedRefs.has(activity); } /** * Toggle activity selection. */ toggleActivity(activity: ActivityInterface): void { - if (this.isSelected(activity)) { + const isSelected = this.isSelected(activity); + const selectedCount = this.selectedState().selectedCount; + + if (isSelected) { + if (selectedCount <= 1) { + return; + } this.activitySelectionService.selectedActivities.deselect(activity); } else { this.activitySelectionService.selectedActivities.select(activity); } } + canDeselectActivity(activity: ActivityInterface): boolean { + return !this.isSelected(activity) || this.selectedState().selectedCount > 1; + } + + isOnlySelectedActivity(activity: ActivityInterface): boolean { + return this.selectedState().selectedCount === 1 && this.isSelected(activity); + } + + async renameDevice(activity: ActivityInterface): Promise { + const user = this.user(); + const event = this.event(); + if (!user || !event) { + return; + } + + const currentName = `${activity.creator?.name ?? ''}`.trim(); + const dialogRef = this.dialog.open(DeviceNameEditDialogComponent, { + width: '420px', + data: { + activityID: activity.getID(), + currentName, + swInfo: activity.creator?.swInfo || '', + }, + }); + + const newName = await firstValueFrom(dialogRef.afterClosed()); + if (!newName) { + return; + } + + const creator = activity.creator; + if (!creator) { + this.snackBar.open('Could not update device name', undefined, { duration: 3500 }); + return; + } + + const previousName = creator.name; + creator.name = newName; + + try { + event.addStat(new DataDeviceNames(event.getActivities().map((eventActivity) => eventActivity.creator?.name || ''))); + await this.eventService.writeActivityAndEventData(user, event, activity); + this.snackBar.open('Device name updated', undefined, { duration: 2500 }); + } catch { + creator.name = previousName; + this.snackBar.open('Could not update device name', undefined, { duration: 3500 }); + } finally { + this.changeDetectorRef.markForCheck(); + } + } + /** * Get the device display name for an activity. */ @@ -95,6 +180,6 @@ export class ActivitiesTogglesComponent implements OnInit { * Track activities by ID for better rendering performance. */ trackByActivityId(index: number, activity: ActivityInterface): string { - return activity.getID(); + return activity.getID() || `idx-${index}`; } } diff --git a/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.css b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.css new file mode 100644 index 000000000..98b07c632 --- /dev/null +++ b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.css @@ -0,0 +1,9 @@ +.full-width { + width: 100%; +} + +.hint { + margin: 0 0 8px; + color: var(--mat-app-on-surface-variant); + font-size: 0.85rem; +} diff --git a/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.html b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.html new file mode 100644 index 000000000..6702463c7 --- /dev/null +++ b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.html @@ -0,0 +1,32 @@ +

Rename device

+ + +

Rename only this activity's device label.

+ @if (data.swInfo) { +

Software info: {{ data.swInfo }}

+ } + +
+ + Device name + + {{ (form.value.deviceName || '').length }}/{{ maxLength }} + @if (form.controls.deviceName.touched && form.controls.deviceName.hasError('required')) { + Device name is required. + } + @if (form.controls.deviceName.touched && form.controls.deviceName.hasError('minlength')) { + Minimum {{ minLength }} characters. + } + @if (form.controls.deviceName.touched && form.controls.deviceName.hasError('maxlength')) { + Maximum {{ maxLength }} characters. + } + +
+
+ + + + + diff --git a/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.spec.ts b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.spec.ts new file mode 100644 index 000000000..d78371b37 --- /dev/null +++ b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +import { DeviceNameEditDialogComponent } from './device-name-edit-dialog.component'; + +describe('DeviceNameEditDialogComponent', () => { + let component: DeviceNameEditDialogComponent; + let fixture: ComponentFixture; + let dialogRef: { close: ReturnType }; + + beforeEach(async () => { + dialogRef = { close: vi.fn() }; + + await TestBed.configureTestingModule({ + declarations: [DeviceNameEditDialogComponent], + imports: [ReactiveFormsModule], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + activityID: 'a1', + currentName: 'Garmin Edge', + swInfo: '21.19', + }, + }, + { provide: MatDialogRef, useValue: dialogRef }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DeviceNameEditDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('initializes form with current device name', () => { + expect(component.form.value.deviceName).toBe('Garmin Edge'); + }); + + it('validates required and minimum length', () => { + component.form.controls.deviceName.setValue(''); + expect(component.form.controls.deviceName.hasError('required')).toBe(true); + + component.form.controls.deviceName.setValue('ab'); + expect(component.form.controls.deviceName.hasError('minlength')).toBe(true); + }); + + it('closes with trimmed name on save when changed and valid', () => { + component.form.controls.deviceName.setValue(' New Name '); + + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith('New Name'); + }); + + it('does not close with value when name is unchanged', () => { + component.form.controls.deviceName.setValue('Garmin Edge'); + + component.save(); + + expect(dialogRef.close).not.toHaveBeenCalledWith('Garmin Edge'); + }); + + it('close() dismisses dialog', () => { + component.close(); + + expect(dialogRef.close).toHaveBeenCalledWith(); + }); +}); diff --git a/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.ts b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.ts new file mode 100644 index 000000000..d72aac863 --- /dev/null +++ b/src/app/components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component.ts @@ -0,0 +1,59 @@ +import { Component, Inject, inject } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +export interface DeviceNameEditDialogData { + activityID: string; + currentName: string; + swInfo?: string; +} + +@Component({ + selector: 'app-device-name-edit-dialog', + templateUrl: './device-name-edit-dialog.component.html', + styleUrls: ['./device-name-edit-dialog.component.css'], + standalone: false, +}) +export class DeviceNameEditDialogComponent { + readonly minLength = 3; + readonly maxLength = 20; + private formBuilder = inject(FormBuilder); + + readonly form; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: DeviceNameEditDialogData, + private dialogRef: MatDialogRef, + ) { + this.data.currentName = `${this.data.currentName ?? ''}`.trim(); + this.form = this.formBuilder.group({ + deviceName: [ + this.data.currentName, + [Validators.required, Validators.minLength(this.minLength), Validators.maxLength(this.maxLength)], + ], + }); + } + + close(): void { + this.dialogRef.close(); + } + + save(): void { + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + + const trimmedName = `${this.form.value.deviceName ?? ''}`.trim(); + if (!trimmedName || trimmedName === this.data.currentName) { + return; + } + + this.dialogRef.close(trimmedName); + } + + get isUnchanged(): boolean { + const trimmedName = `${this.form.value.deviceName ?? ''}`.trim(); + return trimmedName === this.data.currentName; + } +} diff --git a/src/app/components/event/activity-toggle/activity-toggle.component.html b/src/app/components/event/activity-toggle/activity-toggle.component.html index 69df0edd6..83b5d91bf 100644 --- a/src/app/components/event/activity-toggle/activity-toggle.component.html +++ b/src/app/components/event/activity-toggle/activity-toggle.component.html @@ -6,7 +6,7 @@
} - {{activity().type}} @if (event().isMerge) { @@ -27,4 +27,4 @@ @if (user() && isOwner() && showActions()) { } - \ No newline at end of file + diff --git a/src/app/components/event/activity-toggle/activity-toggle.component.spec.ts b/src/app/components/event/activity-toggle/activity-toggle.component.spec.ts new file mode 100644 index 000000000..64c52c8c3 --- /dev/null +++ b/src/app/components/event/activity-toggle/activity-toggle.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { ActivityToggleComponent } from './activity-toggle.component'; +import { AppActivitySelectionService } from '../../../services/activity-selection-service/app-activity-selection.service'; +import { AppEventColorService } from '../../../services/color/app.event.color.service'; +import { MatSlideToggleChange } from '@angular/material/slide-toggle'; + +const createActivity = (id: string): any => ({ + getID: () => id, + type: 'Run', + creator: { name: 'Garmin', swInfo: '1.0' }, + startDate: new Date('2025-01-01T10:00:00.000Z'), + getDuration: () => ({ getDisplayValue: () => '1:00:00' }), + getDistance: () => ({ getDisplayValue: () => 10, getDisplayUnit: () => 'km' }), +}); + +describe('ActivityToggleComponent', () => { + let component: ActivityToggleComponent; + let fixture: ComponentFixture; + + const mockSelectionService = { + selectedActivities: { + select: vi.fn(), + deselect: vi.fn(), + }, + }; + + const mockColorService = { + getActivityColor: vi.fn(() => '#ff0000'), + }; + + const setRequiredInputs = (activity: any, selectedActivities: any[]) => { + const event = { + isMerge: true, + getActivities: () => [activity], + } as any; + + fixture.componentRef.setInput('event', event); + fixture.componentRef.setInput('activity', activity); + fixture.componentRef.setInput('selectedActivities', selectedActivities); + fixture.detectChanges(); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + await TestBed.configureTestingModule({ + declarations: [ActivityToggleComponent], + providers: [ + { provide: AppActivitySelectionService, useValue: mockSelectionService }, + { provide: AppEventColorService, useValue: mockColorService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ActivityToggleComponent); + component = fixture.componentInstance; + }); + + it('treats same-ID activity as selected even when object references differ', () => { + const renderedActivity = createActivity('a1'); + const selectedClone = createActivity('a1'); + + setRequiredInputs(renderedActivity, [selectedClone]); + + expect(component.isSelected()).toBe(true); + }); + + it('deselects using the selected reference when IDs match but refs differ', () => { + const renderedActivity = createActivity('a1'); + const selectedClone = createActivity('a1'); + + setRequiredInputs(renderedActivity, [selectedClone]); + + component.onActivityClick(renderedActivity); + + expect(mockSelectionService.selectedActivities.deselect).toHaveBeenCalledWith(selectedClone); + expect(mockSelectionService.selectedActivities.select).not.toHaveBeenCalled(); + }); + + it('does not select again from slide toggle when same ID is already selected', () => { + const renderedActivity = createActivity('a1'); + const selectedClone = createActivity('a1'); + + setRequiredInputs(renderedActivity, [selectedClone]); + + component.onActivitySelect({ checked: true } as MatSlideToggleChange, renderedActivity); + + expect(mockSelectionService.selectedActivities.select).not.toHaveBeenCalled(); + expect(mockSelectionService.selectedActivities.deselect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/event/activity-toggle/activity-toggle.component.ts b/src/app/components/event/activity-toggle/activity-toggle.component.ts index 04184f0a2..bf649f6af 100644 --- a/src/app/components/event/activity-toggle/activity-toggle.component.ts +++ b/src/app/components/event/activity-toggle/activity-toggle.component.ts @@ -2,13 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, - DestroyRef, inject, input, - OnInit } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { debounceTime } from 'rxjs/operators'; import { EventInterface, ActivityInterface, User } from '@sports-alliance/sports-lib'; import { AppEventColorService } from '../../../services/color/app.event.color.service'; import { AppActivitySelectionService } from '../../../services/activity-selection-service/app-activity-selection.service'; @@ -21,7 +17,7 @@ import { MatSlideToggleChange } from '@angular/material/slide-toggle'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) -export class ActivityToggleComponent implements OnInit { +export class ActivityToggleComponent { // Signal inputs event = input.required(); activity = input.required(); @@ -34,28 +30,56 @@ export class ActivityToggleComponent implements OnInit { showStats = input(true); // Injected services - private destroyRef = inject(DestroyRef); public eventColorService = inject(AppEventColorService); public activitySelectionService = inject(AppActivitySelectionService); // Computed: cache selection status - isSelected = computed(() => this.selectedActivities().some(a => a.getID() === this.activity().getID())); + isSelected = computed(() => this.isActivitySelected(this.activity())); // Computed: cache activity color activityColor = computed(() => this.eventColorService.getActivityColor(this.event().getActivities(), this.activity())); - ngOnInit() { + onActivitySelect(event: MatSlideToggleChange, activity: ActivityInterface) { + if (event.checked) { + this.selectActivity(activity); + return; + } + this.deselectActivity(activity); } - onActivitySelect(event: MatSlideToggleChange, activity: ActivityInterface) { - event.checked - ? this.activitySelectionService.selectedActivities.select(activity) - : this.activitySelectionService.selectedActivities.deselect(activity); + onActivityClick(activity: ActivityInterface): void { + this.isActivitySelected(activity) + ? this.deselectActivity(activity) + : this.selectActivity(activity); + } + + private selectActivity(activity: ActivityInterface): void { + if (this.isActivitySelected(activity)) { + return; + } + this.activitySelectionService.selectedActivities.select(activity); + } + + private deselectActivity(activity: ActivityInterface): void { + const selectedActivityRef = this.findSelectedActivity(activity); + if (!selectedActivityRef) { + return; + } + this.activitySelectionService.selectedActivities.deselect(selectedActivityRef); + } + + private isActivitySelected(activity: ActivityInterface): boolean { + return !!this.findSelectedActivity(activity); } - onActivityClick(event: Event, activity: ActivityInterface) { - this.activitySelectionService.selectedActivities.isSelected(activity) - ? this.activitySelectionService.selectedActivities.deselect(activity) - : this.activitySelectionService.selectedActivities.select(activity); + private findSelectedActivity(activity: ActivityInterface): ActivityInterface | undefined { + const selectedActivities = this.selectedActivities() ?? []; + const activityID = activity?.getID?.(); + + if (activityID) { + return selectedActivities.find((selectedActivity) => selectedActivity?.getID?.() === activityID); + } + + return selectedActivities.find((selectedActivity) => selectedActivity === activity); } } diff --git a/src/app/components/event/chart/actions/event.card.chart.actions.component.html b/src/app/components/event/chart/actions/event.card.chart.actions.component.html index 6d91a846f..3a1477394 100644 --- a/src/app/components/event/chart/actions/event.card.chart.actions.component.html +++ b/src/app/components/event/chart/actions/event.card.chart.actions.component.html @@ -1,38 +1,46 @@
- - - - + + + +
+ + +
+ Stack Y Axes - + +
- - +
+ Show Laps - + +
- - +
+ Show All Data - - - - + +
- -
- - @for (xAxisType of xAxisTypes | keyvalue; track xAxisType) { - - {{xAxisType.key}} - - } - +
+ + X Axis + + @for (xAxisType of xAxisTypes | keyvalue; track xAxisType) { + + {{xAxisType.key}} + + } + +
-
\ No newline at end of file +
diff --git a/src/app/components/event/chart/actions/event.card.chart.actions.component.scss b/src/app/components/event/chart/actions/event.card.chart.actions.component.scss index f74702fcf..762863b83 100644 --- a/src/app/components/event/chart/actions/event.card.chart.actions.component.scss +++ b/src/app/components/event/chart/actions/event.card.chart.actions.component.scss @@ -1,37 +1,50 @@ @use '../../../../../styles/breakpoints' as *; -/* Toggle Actions Container */ +:host { + display: flex; + justify-content: flex-end; + width: 100%; +} + section.container { display: flex; - flex-wrap: wrap; - gap: 12px; + justify-content: flex-end; align-items: center; - padding: 4px 0; + width: 100%; } -.spacer { - flex: 1; +.options-trigger { + min-width: 0; } -section.item { - display: inline-flex; - align-items: center; +.options-trigger mat-icon { + margin-right: 4px; +} + +.chart-option-toggle { + width: 100%; +} + +.x-axis-field { + width: 100%; + min-width: 220px; +} + +section[mat-menu-item] { + height: auto; + min-height: unset; +} + +section[mat-menu-item]:hover { + background: transparent; } -/* Responsive: Stack on mobile */ @include xsmall { section.container { - flex-direction: column; - align-items: flex-start; - gap: 16px; + justify-content: flex-end; } - .spacer { - display: none; + .x-axis-field { + min-width: 180px; } - - section.item { - width: 100%; - max-width: 100%; - } -} \ No newline at end of file +} diff --git a/src/app/components/event/chart/actions/event.card.chart.actions.component.spec.ts b/src/app/components/event/chart/actions/event.card.chart.actions.component.spec.ts new file mode 100644 index 000000000..939e8e4a4 --- /dev/null +++ b/src/app/components/event/chart/actions/event.card.chart.actions.component.spec.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { EventInterface, User, XAxisTypes } from '@sports-alliance/sports-lib'; +import { vi } from 'vitest'; +import { EventCardChartActionsComponent } from './event.card.chart.actions.component'; +import { AppAnalyticsService } from '../../../../services/app.analytics.service'; + +describe('EventCardChartActionsComponent', () => { + let component: EventCardChartActionsComponent; + let fixture: ComponentFixture; + + const analyticsServiceMock = { + logEvent: vi.fn(), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EventCardChartActionsComponent], + imports: [ + CommonModule, + BrowserAnimationsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatMenuModule, + MatSelectModule, + MatSlideToggleModule, + MatTooltipModule, + ], + providers: [ + { provide: AppAnalyticsService, useValue: analyticsServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EventCardChartActionsComponent); + component = fixture.componentInstance; + component.user = { uid: 'test-user' } as User; + component.event = { isMultiSport: () => false } as EventInterface; + component.xAxisType = XAxisTypes.Duration; + component.showAllData = false; + component.showLaps = false; + component.stackYAxes = false; + fixture.detectChanges(); + vi.clearAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should use form menu panel classes', () => { + const templatePath = resolve(process.cwd(), 'src/app/components/event/chart/actions/event.card.chart.actions.component.html'); + const template = readFileSync(templatePath, 'utf8'); + expect(template).toMatch(/]*class="[^"]*qs-menu-panel[^"]*qs-menu-panel-form[^"]*qs-config-menu[^"]*"/); + }); + + it('should apply submenu panel class to x-axis select', () => { + const templatePath = resolve(process.cwd(), 'src/app/components/event/chart/actions/event.card.chart.actions.component.html'); + const template = readFileSync(templatePath, 'utf8'); + expect(template).toContain(' { + const stackYAxesEmitSpy = vi.spyOn(component.stackYAxesChange, 'emit'); + + await component.onStackYAxesToggle(true); + + expect(component.stackYAxes).toBe(true); + expect(stackYAxesEmitSpy).toHaveBeenCalledWith(true); + expect(analyticsServiceMock.logEvent).toHaveBeenCalledWith('event_chart_settings_change', { property: 'stackYAxes' }); + }); + + it('should emit xAxisType changes and log analytics', async () => { + const xAxisTypeEmitSpy = vi.spyOn(component.xAxisTypeChange, 'emit'); + + await component.onXAxisTypeChange(XAxisTypes.Distance); + + expect(component.xAxisType).toBe(XAxisTypes.Distance); + expect(xAxisTypeEmitSpy).toHaveBeenCalledWith(XAxisTypes.Distance); + expect(analyticsServiceMock.logEvent).toHaveBeenCalledWith('event_chart_settings_change', { property: 'xAxisType' }); + }); + + it('should emit all changes on fallback and log analytics', async () => { + const showAllDataEmitSpy = vi.spyOn(component.showAllDataChange, 'emit'); + const showLapsEmitSpy = vi.spyOn(component.showLapsChange, 'emit'); + const stackYAxesEmitSpy = vi.spyOn(component.stackYAxesChange, 'emit'); + const xAxisTypeEmitSpy = vi.spyOn(component.xAxisTypeChange, 'emit'); + + component.showAllData = true; + component.showLaps = true; + component.stackYAxes = true; + component.xAxisType = XAxisTypes.Time; + + await component.somethingChanged(); + + expect(showAllDataEmitSpy).toHaveBeenCalledWith(true); + expect(showLapsEmitSpy).toHaveBeenCalledWith(true); + expect(stackYAxesEmitSpy).toHaveBeenCalledWith(true); + expect(xAxisTypeEmitSpy).toHaveBeenCalledWith(XAxisTypes.Time); + expect(analyticsServiceMock.logEvent).toHaveBeenCalledWith('event_chart_settings_change', { property: undefined }); + }); +}); diff --git a/src/app/components/event/chart/actions/event.card.chart.actions.component.ts b/src/app/components/event/chart/actions/event.card.chart.actions.component.ts index d0c0aea8a..4906ec2a3 100644 --- a/src/app/components/event/chart/actions/event.card.chart.actions.component.ts +++ b/src/app/components/event/chart/actions/event.card.chart.actions.component.ts @@ -31,6 +31,26 @@ export class EventCardChartActionsComponent implements OnChanges { constructor() { } + async onStackYAxesToggle(checked: boolean) { + this.stackYAxes = checked; + await this.somethingChanged('stackYAxes'); + } + + async onShowLapsToggle(checked: boolean) { + this.showLaps = checked; + await this.somethingChanged('showLaps'); + } + + async onShowAllDataToggle(checked: boolean) { + this.showAllData = checked; + await this.somethingChanged('showAllData'); + } + + async onXAxisTypeChange(value: XAxisTypes) { + this.xAxisType = value; + await this.somethingChanged('xAxisType'); + } + async somethingChanged(prop?: string) { if (prop === 'xAxisType') { this.xAxisTypeChange.emit(this.xAxisType); diff --git a/src/app/components/event/chart/event.card.chart.component.html b/src/app/components/event/chart/event.card.chart.component.html index 9ca3d0122..7e1ff068e 100644 --- a/src/app/components/event/chart/event.card.chart.component.html +++ b/src/app/components/event/chart/event.card.chart.component.html @@ -1,19 +1,14 @@ -
-
- show_chart -
-
- - -
-
+ + + + - +
-
\ No newline at end of file + diff --git a/src/app/components/event/chart/event.card.chart.component.scss b/src/app/components/event/chart/event.card.chart.component.scss index 82a731a05..b72392958 100644 --- a/src/app/components/event/chart/event.card.chart.component.scss +++ b/src/app/components/event/chart/event.card.chart.component.scss @@ -1,38 +1,7 @@ -@use '../../../../styles/breakpoints' as *; - -.chart-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - gap: 16px; - flex-wrap: wrap; - - @include xsmall { - padding: 8px; - gap: 8px; - } -} - -.chart-header-avatar { - display: flex; - align-items: center; - justify-content: center; -} - -.chart-header-actions { - flex: 1; - - @include xsmall { - width: 100%; - flex: none; - } -} - /* Ensure the component inside expands to fill the flex space */ app-event-card-chart-actions { display: block; width: 100%; } -/* chart-progress-bar removed, handled by app-loading-overlay */ \ No newline at end of file +/* chart-progress-bar removed, handled by app-loading-overlay */ diff --git a/src/app/components/event/chart/event.card.chart.component.spec.ts b/src/app/components/event/chart/event.card.chart.component.spec.ts index 38680ef73..45690737a 100644 --- a/src/app/components/event/chart/event.card.chart.component.spec.ts +++ b/src/app/components/event/chart/event.card.chart.component.spec.ts @@ -13,9 +13,10 @@ import { AppChartSettingsLocalStorageService } from '../../../services/storage/a import { AppActivityCursorService } from '../../../services/activity-cursor/app-activity-cursor.service'; import { LoggerService } from '../../../services/logger.service'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ChangeDetectorRef, NgZone, signal } from '@angular/core'; +import { ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, NgZone, signal } from '@angular/core'; import { of } from 'rxjs'; -import { ActivityTypes, DataAltitude } from '@sports-alliance/sports-lib'; +import { ActivityTypes, DataAltitude, DataPowerAvg, DataSpeedAvgKilometersPerHour, LapTypes, XAxisTypes } from '@sports-alliance/sports-lib'; +import { AppUserUtilities } from '../../../utils/app.user.utilities'; describe('EventCardChartComponent', () => { let component: EventCardChartComponent; @@ -54,7 +55,9 @@ describe('EventCardChartComponent', () => { getChartTheme: vi.fn().mockReturnValue({}), load: vi.fn().mockResolvedValue({ core: {}, charts: {} }) }; - mockEventColorService = {}; + mockEventColorService = { + getActivityColor: vi.fn().mockReturnValue('#ff0000') + }; mockUserService = { getUser: vi.fn().mockReturnValue(of({})), getUserChartDataTypesToUse: vi.fn().mockReturnValue([]) @@ -93,7 +96,8 @@ describe('EventCardChartComponent', () => { { provide: AppActivityCursorService, useValue: mockActivityCursorService }, { provide: MatSnackBar, useValue: mockSnackBar }, { provide: LoggerService, useValue: { error: vi.fn(), warn: vi.fn(), log: vi.fn(), info: vi.fn() } }, - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(EventCardChartComponent); @@ -138,6 +142,15 @@ describe('EventCardChartComponent', () => { expect(component).toBeTruthy(); }); + it('should fallback to default chart lap types when settings lapTypes is empty', () => { + mockUserSettingsQuery.chartSettings.set({ + ...mockUserSettingsQuery.chartSettings(), + lapTypes: [] + }); + + expect(component.lapTypes).toEqual(AppUserUtilities.getDefaultChartLapTypes()); + }); + describe('createLabel', () => { it('should include gain and loss for Running', () => { component.event = { @@ -185,4 +198,400 @@ describe('EventCardChartComponent', () => { expect(label).toBeDefined(); }); }); + + describe('createOrUpdateChartSeries labels', () => { + it('should normalize unit-derived speed label in tooltip, legend, and dummyData displayName', () => { + const activity = { + creator: { name: 'Garmin' }, + getID: () => 'a1' + } as any; + const stream = { type: DataSpeedAvgKilometersPerHour.type } as any; + + component.event = { + getActivities: () => [activity, { creator: { name: 'Coros' }, getID: () => 'a2' }], + isMultiSport: () => false, + getActivityTypesAsArray: () => [ActivityTypes.Cycling], + } as any; + + (component as any).chart = { + isDisposed: () => false, + series: { + values: [], + push: vi.fn((series: any) => series), + }, + yAxes: { + getIndex: vi.fn().mockReturnValue({}), + push: vi.fn().mockReturnValue({}), + }, + }; + (component as any).charts = { + LineSeries: function () { + return { + adapter: { add: vi.fn() }, + events: { on: vi.fn() }, + dataFields: {}, + legendSettings: {}, + }; + }, + }; + + vi.spyOn(component as any, 'attachSeriesEventListeners').mockImplementation(() => { }); + vi.spyOn(component as any, 'convertStreamDataToSeriesData').mockReturnValue([]); + vi.spyOn(component as any, 'getYAxisForSeries').mockReturnValue({}); + + const series = (component as any).createOrUpdateChartSeries(activity, stream); + + expect(series).toBeTruthy(); + expect(series.dummyData.displayName).toBe('Average Speed'); + expect(series.tooltipText).toContain('Average Speed'); + expect(series.legendSettings.labelText).toContain('Average Speed'); + }); + + it('should keep non-unit-derived power labels unchanged', () => { + const activity = { + creator: { name: 'Garmin' }, + getID: () => 'a1' + } as any; + const stream = { type: DataPowerAvg.type } as any; + + component.event = { + getActivities: () => [activity, { creator: { name: 'Coros' }, getID: () => 'a2' }], + isMultiSport: () => false, + getActivityTypesAsArray: () => [ActivityTypes.Cycling], + } as any; + + (component as any).chart = { + isDisposed: () => false, + series: { + values: [], + push: vi.fn((series: any) => series), + }, + yAxes: { + getIndex: vi.fn().mockReturnValue({}), + push: vi.fn().mockReturnValue({}), + }, + }; + (component as any).charts = { + LineSeries: function () { + return { + adapter: { add: vi.fn() }, + events: { on: vi.fn() }, + dataFields: {}, + legendSettings: {}, + }; + }, + }; + + vi.spyOn(component as any, 'attachSeriesEventListeners').mockImplementation(() => { }); + vi.spyOn(component as any, 'convertStreamDataToSeriesData').mockReturnValue([]); + vi.spyOn(component as any, 'getYAxisForSeries').mockReturnValue({}); + + const series = (component as any).createOrUpdateChartSeries(activity, stream); + + expect(series).toBeTruthy(); + expect(series.dummyData.displayName).toBe('Average Power'); + expect(series.tooltipText).toContain('Average Power'); + expect(series.legendSettings.labelText).toContain('Average Power'); + }); + }); + + describe('addLapGuides', () => { + it('should normalize lap types from source data when rendering guides', () => { + const createdRanges: any[] = []; + const xAxis = { + axisRanges: { + template: { grid: { disabled: true } }, + create: vi.fn(() => { + const range = { + value: 0, + grid: { + disabled: false, + stroke: null, + strokeWidth: 0, + strokeOpacity: 0, + strokeDasharray: '', + above: false, + zIndex: 0, + tooltipText: '', + tooltipPosition: '' + }, + label: { + text: '', + tooltipText: '', + inside: false, + paddingTop: 0, + paddingBottom: 0, + zIndex: 0, + fontSize: '', + background: { + fillOpacity: 0, + stroke: null, + strokeWidth: 0, + width: 0 + }, + fill: null, + horizontalCenter: '', + valign: '', + textAlign: '', + dy: 0 + } + }; + createdRanges.push(range); + return range; + }) + } + }; + const chart = { + xAxes: { + getIndex: vi.fn(() => xAxis) + } + } as any; + + const activity = { + creator: { name: 'Runner' }, + getID: () => 'activity-1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + getLaps: () => [ + { type: 'manual', endDate: new Date('2026-01-01T00:01:00.000Z') }, + { type: 'session_end', endDate: new Date('2026-01-01T00:02:00.000Z') } + ] + } as any; + + (component as any).addLapGuides(chart, [activity], XAxisTypes.Duration, [LapTypes.Manual]); + + expect(createdRanges).toHaveLength(1); + expect(createdRanges[0].label.text).toBe('1'); + expect(createdRanges[0].date.getTime()).toBe(60_000); + }); + + it('should assign each lap guide its own label text', () => { + const createdRanges: any[] = []; + const xAxis = { + axisRanges: { + template: { grid: { disabled: true } }, + create: vi.fn(() => { + const range = { + value: 0, + grid: { + disabled: false, + stroke: null, + strokeWidth: 0, + strokeOpacity: 0, + strokeDasharray: '', + above: false, + zIndex: 0, + tooltipText: '', + tooltipPosition: '' + }, + label: { + text: '', + tooltipText: '', + inside: false, + paddingTop: 0, + paddingBottom: 0, + zIndex: 0, + fontSize: '', + background: { + fillOpacity: 0, + stroke: null, + strokeWidth: 0, + width: 0 + }, + fill: null, + horizontalCenter: '', + valign: '', + textAlign: '', + dy: 0 + } + }; + createdRanges.push(range); + return range; + }) + } + }; + + const chart = { + xAxes: { + getIndex: vi.fn(() => xAxis) + } + } as any; + + const activity = { + creator: { name: 'Runner' }, + getID: () => 'activity-1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + getLaps: () => [ + { type: LapTypes.Manual, endDate: new Date('2026-01-01T00:01:00.000Z') }, + { type: LapTypes.Manual, endDate: new Date('2026-01-01T00:02:00.000Z') }, + { type: LapTypes.Start, endDate: new Date('2026-01-01T00:03:00.000Z') } + ] + } as any; + + (component as any).addLapGuides(chart, [activity], XAxisTypes.Duration, [LapTypes.Manual]); + + expect(createdRanges).toHaveLength(2); + expect(createdRanges[0].label.text).toBe('1'); + expect(createdRanges[1].label.text).toBe('2'); + expect(createdRanges[0].date.getTime()).toBe(60_000); + expect(createdRanges[1].date.getTime()).toBe(120_000); + }); + + it('should place time-axis guides at absolute lap end time', () => { + const createdRanges: any[] = []; + const xAxis = { + axisRanges: { + template: { grid: { disabled: true } }, + create: vi.fn(() => { + const range = { + date: null, + grid: { + disabled: false, + stroke: null, + strokeWidth: 0, + strokeOpacity: 0, + strokeDasharray: '', + above: false, + zIndex: 0, + tooltipText: '', + tooltipPosition: '' + }, + label: { + text: '', + tooltipText: '', + inside: false, + paddingTop: 0, + paddingBottom: 0, + zIndex: 0, + fontSize: '', + background: { + fillOpacity: 0, + stroke: null, + strokeWidth: 0, + width: 0 + }, + fill: null, + horizontalCenter: '', + valign: '', + textAlign: '', + dy: 0 + } + }; + createdRanges.push(range); + return range; + }) + } + }; + + const chart = { + xAxes: { + getIndex: vi.fn(() => xAxis) + } + } as any; + + const lapEnd = new Date('2026-01-01T00:01:00.000Z'); + const activity = { + creator: { name: 'Runner' }, + getID: () => 'activity-1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + getLaps: () => [ + { type: LapTypes.Manual, endDate: lapEnd }, + { type: LapTypes.Start, endDate: new Date('2026-01-01T00:03:00.000Z') } + ] + } as any; + + (component as any).addLapGuides(chart, [activity], XAxisTypes.Time, [LapTypes.Manual]); + + expect(createdRanges).toHaveLength(1); + expect(createdRanges[0].date.getTime()).toBe(lapEnd.getTime()); + }); + + it('should fallback to cumulative lap duration when indoor lap timestamps collapse to start time', () => { + const createdRanges: any[] = []; + const xAxis = { + axisRanges: { + template: { grid: { disabled: true } }, + create: vi.fn(() => { + const range = { + date: null, + value: 0, + grid: { + disabled: false, + stroke: null, + strokeWidth: 0, + strokeOpacity: 0, + strokeDasharray: '', + above: false, + zIndex: 0, + tooltipText: '', + tooltipPosition: '' + }, + label: { + text: '', + tooltipText: '', + inside: false, + paddingTop: 0, + paddingBottom: 0, + zIndex: 0, + fontSize: '', + background: { + fillOpacity: 0, + stroke: null, + strokeWidth: 0, + width: 0 + }, + fill: null, + horizontalCenter: '', + valign: '', + textAlign: '', + dy: 0 + } + }; + createdRanges.push(range); + return range; + }) + } + }; + + const chart = { + xAxes: { + getIndex: vi.fn(() => xAxis) + } + } as any; + + const start = new Date('2026-01-01T00:00:00.000Z'); + const activity = { + creator: { name: 'Trainer Ride' }, + type: 'Indoor Cycling', + isTrainer: () => true, + getID: () => 'activity-indoor', + startDate: start, + getLaps: () => [ + { + type: LapTypes.Manual, + startDate: start, + endDate: start, + getDuration: () => ({ getValue: () => 60 }) + }, + { + type: LapTypes.Manual, + startDate: start, + endDate: start, + getDuration: () => ({ getValue: () => 75 }) + }, + { + type: LapTypes.Start, + startDate: start, + endDate: start, + getDuration: () => ({ getValue: () => 10 }) + } + ] + } as any; + + (component as any).addLapGuides(chart, [activity], XAxisTypes.Duration, [LapTypes.Manual]); + + expect(createdRanges).toHaveLength(2); + expect(createdRanges[0].date.getTime()).toBe(60_000); + expect(createdRanges[1].date.getTime()).toBe(135_000); + }); + }); }); diff --git a/src/app/components/event/chart/event.card.chart.component.ts b/src/app/components/event/chart/event.card.chart.component.ts index d368c3a89..ebfe13b0c 100644 --- a/src/app/components/event/chart/event.card.chart.component.ts +++ b/src/app/components/event/chart/event.card.chart.component.ts @@ -98,6 +98,7 @@ import { AppEventInterface } from '../../../../../functions/src/shared/app-event import { AppColors } from '../../../services/color/app.colors'; import { ActivityUtilities } from '@sports-alliance/sports-lib'; import { LoggerService } from '../../../services/logger.service'; +import { normalizeUnitDerivedTypeLabel } from '../../../helpers/stat-label.helper'; const DOWNSAMPLE_AFTER_X_HOURS = 8; const DOWNSAMPLE_FACTOR_PER_HOUR = 1.5; @@ -138,13 +139,29 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O public get showGrid() { return this.userSettingsQuery.chartSettings()?.showGrid ?? true; } public get disableGrouping() { return this.userSettingsQuery.chartSettings()?.disableGrouping ?? false; } public get hideAllSeriesOnInit() { return this.userSettingsQuery.chartSettings()?.hideAllSeriesOnInit ?? false; } - public get lapTypes() { return this.userSettingsQuery.chartSettings()?.lapTypes ?? AppUserUtilities.getDefaultChartLapTypes(); } + public get lapTypes() { + const configuredLapTypes = this.userSettingsQuery.chartSettings()?.lapTypes; + return Array.isArray(configuredLapTypes) && configuredLapTypes.length > 0 + ? configuredLapTypes + : AppUserUtilities.getDefaultChartLapTypes(); + } - public get xAxisType() { return this.userSettingsQuery.chartSettings()?.xAxisType ?? XAxisTypes.Duration; } + public get xAxisType() { return this.xAxisTypeOverride ?? this.userSettingsQuery.chartSettings()?.xAxisType ?? XAxisTypes.Duration; } public set xAxisType(value: XAxisTypes) { - if (value !== this.xAxisType) { - this.userSettingsQuery.updateChartSettings({ xAxisType: value }); + if (value === this.xAxisType) { + return; } + this.xAxisTypeOverride = value; + void this.checkForUpdates('xAxisType-setter'); + void this.userSettingsQuery.updateChartSettings({ xAxisType: value }) + .then(() => { + this.xAxisTypeOverride = null; + }) + .catch((error) => { + this.logger.error('[EventCardChart] Failed to persist xAxisType setting', error); + this.xAxisTypeOverride = null; + void this.checkForUpdates('xAxisType-revert'); + }); } public get downSamplingLevel() { return this.userSettingsQuery.chartSettings()?.downSamplingLevel ?? AppUserUtilities.getDefaultDownSamplingLevel(); } @@ -184,6 +201,7 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O // Track previous state for change detection private previousState: any = {}; + private xAxisTypeOverride: XAxisTypes | null = null; public distanceAxesForActivitiesMap = new Map(); @@ -320,6 +338,7 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O return { ...settings, + xAxisType: this.xAxisType, chartTheme: theme, unitSettings: units, dataTypesToUse: (this.dataTypesToUse || []).sort(), @@ -584,41 +603,54 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O } // Here we have all the data we need + const streamType = series.dummyData.stream.type; + const streamDataClass = DynamicDataLoader.getDataClassFromDataType(streamType); + const averageDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getAverage(data)); + const maxDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getMax(data)); + const minDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getMin(data)); + const minToMaxDiffDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)); const labelData = { - name: DynamicDataLoader.getDataClassFromDataType(series.dummyData.stream.type).displayType || DynamicDataLoader.getDataClassFromDataType(series.dummyData.stream.type).type, + name: normalizeUnitDerivedTypeLabel(streamType, streamDataClass.displayType || streamDataClass.type), average: { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getAverage(data)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getAverage(data)).getDisplayUnit()}` + value: averageDisplay.value, + unit: averageDisplay.unit }, max: { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMax(data)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMax(data)).getDisplayUnit()}` + value: maxDisplay.value, + unit: maxDisplay.unit }, min: { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMin(data)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMin(data)).getDisplayUnit()}` + value: minDisplay.value, + unit: minDisplay.unit }, minToMaxDiff: { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)).getDisplayUnit()}` + value: minToMaxDiffDisplay.value, + unit: minToMaxDiffDisplay.unit } }; if (this.doesDataTypeSupportGainOrLoss(series.dummyData.stream.type)) { + const gainDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getGainOrLoss(data, true, this.gainAndLossThreshold)); + const lossDisplay = this.getDisplayFromDataType(streamType, ActivityUtilities.getGainOrLoss(data, false, this.gainAndLossThreshold)); labelData.gain = { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getGainOrLoss(data, true, this.gainAndLossThreshold)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getGainOrLoss(data, true, this.gainAndLossThreshold)).getDisplayUnit()}` + value: gainDisplay.value, + unit: gainDisplay.unit }; labelData.loss = { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getGainOrLoss(data, false, this.gainAndLossThreshold)).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, ActivityUtilities.getGainOrLoss(data, false, this.gainAndLossThreshold)).getDisplayUnit()}` + value: lossDisplay.value, + unit: lossDisplay.unit }; } - if (this.doesDataTypeSupportSlope(series.dummyData.stream.type) && seriesXAxisType === XAxisTypes.Distance) { + if (this.doesDataTypeSupportSlope(streamType) && seriesXAxisType === XAxisTypes.Distance) { + const distanceRange = ((end as number) - (start as number)); + const slopeValue = distanceRange !== 0 + ? (ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)) / distanceRange * 100 + : null; + const slopeDisplay = this.getDisplayFromDataType(streamType, slopeValue); labelData.slopePercentage = { - value: data.length ? `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, (ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)) / ((end as number) - (start as number)) * 100).getDisplayValue()}` : '--', - unit: `${DynamicDataLoader.getDataInstanceFromDataType(series.dummyData.stream.type, (ActivityUtilities.getMax(data) - ActivityUtilities.getMin(data)) / ((end as number) - (start as number)) * 100).getDisplayUnit()}` + value: slopeDisplay.value, + unit: slopeDisplay.unit }; } @@ -1291,16 +1323,23 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O series.dummyData = { activity: activity, stream: stream, - displayName: DynamicDataLoader.getDataClassFromDataType(stream.type).displayType + displayName: normalizeUnitDerivedTypeLabel( + stream.type, + DynamicDataLoader.getDataClassFromDataType(stream.type).displayType || DynamicDataLoader.getDataClassFromDataType(stream.type).type + ) }; this.attachSeriesEventListeners(series); + const streamDataClass = DynamicDataLoader.getDataClassFromDataType(stream.type); + const normalizedLabel = normalizeUnitDerivedTypeLabel(stream.type, streamDataClass.displayType || streamDataClass.type); + const activityPrefix = this.event.getActivities().length === 1 || this.event.isMultiSport() ? '' : `${activity.creator.name} `; + series.tooltipText = ([DataPace.type, DataSwimPace.type, DataSwimPaceMinutesPer100Yard.type, DataPaceMinutesPerMile.type, DataGradeAdjustedPace.type, DataGradeAdjustedPaceMinutesPerMile.type].indexOf(stream.type) !== -1) ? - `${this.event.getActivities().length === 1 || this.event.isMultiSport() ? '' : activity.creator.name} ${DynamicDataLoader.getDataClassFromDataType(stream.type).type} {valueY.formatDuration()} ${DynamicDataLoader.getDataClassFromDataType(stream.type).unit}` - : `${this.event.getActivities().length === 1 || this.event.isMultiSport() ? '' : activity.creator.name} ${DynamicDataLoader.getDataClassFromDataType(stream.type).displayType || DynamicDataLoader.getDataClassFromDataType(stream.type).type} {valueY} ${DynamicDataLoader.getDataClassFromDataType(stream.type).unit}`; + `${activityPrefix}${normalizedLabel} {valueY.formatDuration()} ${streamDataClass.unit}` + : `${activityPrefix}${normalizedLabel} {valueY} ${streamDataClass.unit}`; - series.legendSettings.labelText = `${DynamicDataLoader.getDataClassFromDataType(stream.type).displayType || DynamicDataLoader.getDataClassFromDataType(stream.type).type} ` + (DynamicDataLoader.getDataClassFromDataType(stream.type).unit ? ` (${DynamicDataLoader.getDataClassFromDataType(stream.type).unit})` : '') + ` [${this.core.color(this.eventColorService.getActivityColor(this.event.getActivities(), activity)).toString()}]${this.event.getActivities().length === 1 || this.event.isMultiSport() ? '' : activity.creator.name}[/]`; + series.legendSettings.labelText = `${normalizedLabel} ` + (streamDataClass.unit ? ` (${streamDataClass.unit})` : '') + ` [${this.core.color(this.eventColorService.getActivityColor(this.event.getActivities(), activity)).toString()}]${this.event.getActivities().length === 1 || this.event.isMultiSport() ? '' : activity.creator.name}[/]`; series.adapter.add('fill', (fill, target) => { return this.getSeriesColor(target); @@ -1444,14 +1483,24 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O if (axisSeries.hidden) { return; } - if (DynamicDataLoader.dataTypeMinDataType[axisSeries.dummyData.stream.type]) { - map.min += `${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeMinDataType[axisSeries.dummyData.stream.type]).getDisplayValue()}${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeMinDataType[axisSeries.dummyData.stream.type]).getDisplayUnit()}` + const activity = axisSeries?.dummyData?.activity; + const streamType = axisSeries?.dummyData?.stream?.type; + if (!activity || !streamType) { + return; } - if (DynamicDataLoader.dataTypeAvgDataType[axisSeries.dummyData.stream.type]) { - map.avg += `${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeAvgDataType[axisSeries.dummyData.stream.type]).getDisplayValue()}${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeAvgDataType[axisSeries.dummyData.stream.type]).getDisplayUnit()}` + + const minDataType = DynamicDataLoader.dataTypeMinDataType[streamType]; + const avgDataType = DynamicDataLoader.dataTypeAvgDataType[streamType]; + const maxDataType = DynamicDataLoader.dataTypeMaxDataType[streamType]; + + if (minDataType) { + map.min += this.getActivityStatDisplay(activity, minDataType); } - if (DynamicDataLoader.dataTypeMaxDataType[axisSeries.dummyData.stream.type]) { - map.max += `${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeMaxDataType[axisSeries.dummyData.stream.type]).getDisplayValue()}${axisSeries.dummyData.activity.getStat(DynamicDataLoader.dataTypeMinDataType[axisSeries.dummyData.stream.type]).getDisplayUnit()}` + if (avgDataType) { + map.avg += this.getActivityStatDisplay(activity, avgDataType); + } + if (maxDataType) { + map.max += this.getActivityStatDisplay(activity, maxDataType); } if (index + 1 !== (target.parent).axis.series.length) { map.min += `, ` @@ -1964,90 +2013,181 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O return `rangeLabelContainer${series.id}`; } + private normalizeLapType(type: string): LapTypes { + return ((LapTypes as Record)[type] || type) as LapTypes; + } + + private toMilliseconds(value: unknown): number | null { + if (value instanceof Date) { + const ts = value.getTime(); + return Number.isFinite(ts) ? ts : null; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'string') { + const ts = Date.parse(value); + return Number.isFinite(ts) ? ts : null; + } + if (value && typeof value === 'object') { + const maybeTimestamp = value as { toMillis?: () => number; toDate?: () => Date; seconds?: number; nanoseconds?: number }; + if (typeof maybeTimestamp.toMillis === 'function') { + const ts = maybeTimestamp.toMillis(); + return Number.isFinite(ts) ? ts : null; + } + if (typeof maybeTimestamp.toDate === 'function') { + return this.toMilliseconds(maybeTimestamp.toDate()); + } + if (typeof maybeTimestamp.seconds === 'number') { + const nanos = typeof maybeTimestamp.nanoseconds === 'number' ? maybeTimestamp.nanoseconds : 0; + const ts = (maybeTimestamp.seconds * 1000) + Math.floor(nanos / 1_000_000); + return Number.isFinite(ts) ? ts : null; + } + } + return null; + } + + private getLapDurationMillis(lap: any): number | null { + try { + if (lap && typeof lap.getDuration === 'function') { + const lapDuration = lap.getDuration(); + const secondsValue = typeof lapDuration?.getValue === 'function' + ? lapDuration.getValue() + : null; + if (typeof secondsValue === 'number' && Number.isFinite(secondsValue) && secondsValue > 0) { + return secondsValue * 1000; + } + } + } catch { + // Ignore and fall through + } + return null; + } + private addLapGuides(chart: am4charts.XYChart, selectedActivities: ActivityInterface[], xAxisType: XAxisTypes, lapTypes: LapTypes[]) { const xAxis = chart.xAxes.getIndex(0); + if (!xAxis) { + return; + } xAxis.axisRanges.template.grid.disabled = false; + const selectedLapTypes = new Set((lapTypes || []).map(type => this.normalizeLapType(type))); + + if (selectedLapTypes.size === 0) { + return; + } + selectedActivities - .forEach((activity, activityIndex) => { + .forEach((activity) => { this.logger.info(`EventCardChartComponent: Rendering laps for activity ID: "${activity.getID() || ''}"`); - // Filter on lapTypes - lapTypes - .forEach(lapType => { - activity - .getLaps() - .filter(lap => lap.type === lapType) - .forEach((lap, lapIndex) => { - if (lapIndex === activity.getLaps().length - 1) { - this.logger.info(`EventCardChartComponent: Skipping last lap for activity ${activity.getID()} (lap ${lapIndex + 1})`); - return; - } - if (this.lapTypes.indexOf(lap.type) === -1) { - this.logger.info(`EventCardChartComponent: Skipping lap type ${lap.type} for activity ${activity.getID()} (not in lapTypes filter)`); - return; - } - this.logger.info(`EventCardChartComponent: Adding lap guide for activity ${activity.getID()}, lap type ${lap.type}, lap index ${lapIndex + 1}`); - let range - if (xAxisType === XAxisTypes.Time) { - range = xAxis.axisRanges.create(); - range.value = lap.endDate.getTime(); - } else if (xAxisType === XAxisTypes.Duration) { - range = xAxis.axisRanges.create(); - range.value = +lap.endDate - +activity.startDate; - } else if (xAxisType === XAxisTypes.Distance && this.distanceAxesForActivitiesMap.get(activity.getID() || '')) { - const data = this.distanceAxesForActivitiesMap - .get(activity.getID() || '') - .getStreamDataByTime(activity.startDate, true) - .filter(streamData => streamData && (streamData.time >= lap.endDate.getTime())); - // There can be a case that the distance stream does not have data for this? - // So if there is a lap, done and the watch did not update the distance example: last 2s lap - if (!data[0]) { - this.logger.warn(`EventCardChartComponent: No distance data found for lap ${lapIndex + 1} of activity ${activity.getID()}`); - return; - } - range = xAxis.axisRanges.create(); - range.value = data[0].value || 0; - } - if (range) { - const defaultColor = (this.chartTheme === 'dark' || this.chartTheme === 'amchartsdark') ? '#ffffff' : '#000000'; - const activityColor = this.eventColorService.getActivityColor(this.event.getActivities(), activity); - const strokeColor = activityColor ? this.core.color(activityColor) : this.core.color(defaultColor); - range.grid.stroke = strokeColor; - - range.grid.strokeWidth = 1.1; - range.grid.strokeOpacity = 1; - range.grid.strokeDasharray = '2,5'; - - range.grid.above = true; - range.grid.zIndex = 1; - range.grid.tooltipText = `[${strokeColor.toString()} bold font-size: 1.2em]${activity.creator.name}[/]\n[bold font-size: 1.0em]Lap #${lapIndex + 1}[/]\n[bold font-size: 1.0em]Type:[/] [font-size: 0.8em]${lapType}[/]`; - range.grid.tooltipPosition = 'pointer'; - - range.label.tooltipText = range.grid.tooltipText; - range.label.inside = true; - range.label.adapter.add('text', () => { - return `${lapIndex + 1}`; - }); - range.label.paddingTop = 2; - range.label.paddingBottom = 2; - range.label.zIndex = 11; - range.label.fontSize = '1em'; - range.label.background.fillOpacity = 1; - range.label.background.stroke = range.grid.stroke; - range.label.background.strokeWidth = 1; - range.label.tooltipText = range.grid.tooltipText; - - // range.label.interactionsEnabled = true; - - range.label.background.width = 1; - range.label.fill = range.grid.stroke; - range.label.horizontalCenter = 'middle'; - range.label.valign = 'bottom'; - range.label.textAlign = 'middle'; - range.label.dy = 6; - } + const activityLaps = activity.getLaps(); + const activityStartMillis = this.toMilliseconds(activity.startDate); + + let cumulativeLapDurationMillis = 0; + + activityLaps.forEach((lap, lapIndex) => { + const lapEndMillis = this.toMilliseconds(lap.endDate); + const lapDurationMillis = this.getLapDurationMillis(lap); + if (lapIndex === activityLaps.length - 1) { + this.logger.info(`EventCardChartComponent: Skipping last lap for activity ${activity.getID()} (lap ${lapIndex + 1})`); + return; + } + + const normalizedLapType = this.normalizeLapType(lap.type); + if (!selectedLapTypes.has(normalizedLapType)) { + return; + } + + this.logger.info(`EventCardChartComponent: Adding lap guide for activity ${activity.getID()}, lap type ${lap.type}, lap index ${lapIndex + 1}`); + let range; + if (xAxisType === XAxisTypes.Time) { + if (lapEndMillis === null) { + this.logger.warn(`EventCardChartComponent: Invalid lap end time for activity ${activity.getID()} (lap ${lapIndex + 1})`); + return; + } + range = (xAxis).axisRanges.create(); + (range).date = new Date(lapEndMillis); + } else if (xAxisType === XAxisTypes.Duration) { + const rawDurationMillis = (lapEndMillis !== null && activityStartMillis !== null) + ? Math.max(0, lapEndMillis - activityStartMillis) + : null; + + let durationMillis = rawDurationMillis; + + // Some indoor files provide broken lap timestamps (all equal to activity start). + // In that case, fall back to cumulative lap-duration stats to place guides. + if (durationMillis === null || durationMillis <= cumulativeLapDurationMillis) { + if (lapDurationMillis !== null) { + cumulativeLapDurationMillis += lapDurationMillis; + durationMillis = cumulativeLapDurationMillis; + } else if (durationMillis !== null) { + durationMillis = Math.max(durationMillis, cumulativeLapDurationMillis); + } else { + this.logger.warn(`EventCardChartComponent: Invalid lap/activity time for duration guide on activity ${activity.getID()} (lap ${lapIndex + 1})`); + return; } - ) - }); + } else { + cumulativeLapDurationMillis = durationMillis; + } + + range = (xAxis).axisRanges.create(); + (range).date = new Date(durationMillis); + } else if (xAxisType === XAxisTypes.Distance && this.distanceAxesForActivitiesMap.get(activity.getID() || '')) { + if (lapEndMillis === null) { + this.logger.warn(`EventCardChartComponent: Invalid lap end time for distance guide on activity ${activity.getID()} (lap ${lapIndex + 1})`); + return; + } + const data = this.distanceAxesForActivitiesMap + .get(activity.getID() || '') + .getStreamDataByTime(activity.startDate, true) + .filter(streamData => streamData && (streamData.time >= lapEndMillis)); + // There can be a case that the distance stream does not have data for this? + // So if there is a lap, done and the watch did not update the distance example: last 2s lap + if (!data[0]) { + this.logger.warn(`EventCardChartComponent: No distance data found for lap ${lapIndex + 1} of activity ${activity.getID()}`); + return; + } + range = xAxis.axisRanges.create(); + range.value = data[0].value || 0; + } + + if (range) { + const defaultColor = (this.chartTheme === 'dark' || this.chartTheme === 'amchartsdark') ? '#ffffff' : '#000000'; + const activityColor = this.eventColorService.getActivityColor(this.event.getActivities(), activity); + const strokeColor = activityColor ? this.core.color(activityColor) : this.core.color(defaultColor); + range.grid.disabled = false; + range.grid.stroke = strokeColor; + + range.grid.strokeWidth = 1.1; + range.grid.strokeOpacity = 1; + range.grid.strokeDasharray = '2,5'; + + range.grid.above = true; + range.grid.zIndex = 1; + range.grid.tooltipText = `[${strokeColor.toString()} bold font-size: 1.2em]${activity.creator.name}[/]\n[bold font-size: 1.0em]Lap #${lapIndex + 1}[/]\n[bold font-size: 1.0em]Type:[/] [font-size: 0.8em]${normalizedLapType}[/]`; + range.grid.tooltipPosition = 'pointer'; + + range.label.tooltipText = range.grid.tooltipText; + range.label.inside = true; + range.label.text = `${lapIndex + 1}`; + range.label.paddingTop = 2; + range.label.paddingBottom = 2; + range.label.zIndex = 11; + range.label.fontSize = '1em'; + range.label.background.fillOpacity = 1; + range.label.background.stroke = range.grid.stroke; + range.label.background.strokeWidth = 1; + range.label.tooltipText = range.grid.tooltipText; + + // range.label.interactionsEnabled = true; + + range.label.background.width = 1; + range.label.fill = range.grid.stroke; + range.label.horizontalCenter = 'middle'; + range.label.valign = 'bottom'; + range.label.textAlign = 'middle'; + range.label.dy = 6; + } + }); }) } @@ -2168,6 +2308,42 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O this.getSubscriptions().forEach(subscription => subscription.unsubscribe()); } + private getDisplayFromDataType(dataType: string, value: unknown): { value: string; unit: string } { + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) { + return { value: '--', unit: '' }; + } + + try { + const data = DynamicDataLoader.getDataInstanceFromDataType(dataType, numericValue); + if (!data) { + return { value: '--', unit: '' }; + } + return { + value: `${data.getDisplayValue()}`, + unit: `${data.getDisplayUnit()}` + }; + } catch (error) { + this.logger.warn('[EventCardChartComponent] Could not format chart value', { + dataType, + numericValue, + error + }); + return { value: '--', unit: '' }; + } + } + + private getActivityStatDisplay(activity: ActivityInterface, dataType: string): string { + if (!dataType) { + return '--'; + } + const stat = activity.getStat(dataType); + if (!stat) { + return '--'; + } + return `${stat.getDisplayValue()}${stat.getDisplayUnit()}`; + } + private addXAxis(chart: am4charts.XYChart, xAxisType: XAxisTypes): am4charts.ValueAxis | am4charts.DateAxis { let xAxis; switch (xAxisType) { @@ -2186,13 +2362,19 @@ export class EventCardChartComponent extends ChartAbstractDirective implements O if (!(target.dataItem as am4charts.ValueAxisDataItem).value) { return ''; } - const data = DynamicDataLoader.getDataInstanceFromDataType(DataDistance.type, (target.dataItem as am4charts.ValueAxisDataItem).value); - return `[bold font-size: 1.0em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` + const display = this.getDisplayFromDataType(DataDistance.type, (target.dataItem as am4charts.ValueAxisDataItem).value); + if (display.value === '--') { + return ''; + } + return `[bold font-size: 1.0em]${display.value}[/]${display.unit}[/]` }); // xAxis.tooltipText = '{valueX}' xAxis.adapter.add('getTooltipText', (text, target) => { - const data = DynamicDataLoader.getDataInstanceFromDataType(DataDistance.type, Number(text)); - return `[bold font-size: 1.0em]${data.getDisplayValue()}[/]${data.getDisplayUnit()}[/]` + const display = this.getDisplayFromDataType(DataDistance.type, text); + if (display.value === '--') { + return ''; + } + return `[bold font-size: 1.0em]${display.value}[/]${display.unit}[/]` }); // xAxis.renderer.labels.template.marginRight = 10; xAxis.min = 0; diff --git a/src/app/components/event/event.card.component.html b/src/app/components/event/event.card.component.html index a3ffd19b3..465f3c271 100644 --- a/src/app/components/event/event.card.component.html +++ b/src/app/components/event/event.card.component.html @@ -8,13 +8,14 @@ [selectedActivities]="selectedActivitiesInstant()" [unitSettings]="userUnitSettings()" [statsToShow]="basicStatsTypes"> - - @if (hasIntensityZonesFlag()) { -
+ + @if (hasPerformanceChartsFlag()) { +
- - + +
} @@ -62,4 +63,4 @@
} -
\ No newline at end of file +
diff --git a/src/app/components/event/event.card.component.css b/src/app/components/event/event.card.component.scss similarity index 86% rename from src/app/components/event/event.card.component.css rename to src/app/components/event/event.card.component.scss index 475ec797b..55828d381 100644 --- a/src/app/components/event/event.card.component.css +++ b/src/app/components/event/event.card.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + /* Dashboard Layout Styles */ .event-dashboard-container { @@ -40,11 +42,6 @@ mat-card-header mat-icon { align-items: center; } -/* --- V2 Layout Tweaks --- */ -.secondary-details-container { - margin-top: 0.5rem; -} - .condensed-stats-row { margin-top: 1rem; margin-bottom: 0.5rem; @@ -80,19 +77,25 @@ mat-card-header mat-icon { /* Ensure identical padding and sizing for alignment */ .secondary-details-container { - padding: 16px; + padding: 12px 40px; + padding-top: 0; box-sizing: border-box; width: 100%; } -.intensity-zones-summary-wrapper { +@include bp.xsmall { + .secondary-details-container { + padding-left: 12px; + padding-right: 12px; + } +} + +.performance-charts-summary-wrapper { display: flex; flex-direction: column; - gap: 16px; width: 100%; - margin-top: 16px; } -.intensity-zones-summary-wrapper mat-divider { +.performance-charts-summary-wrapper mat-divider { margin-bottom: 8px; -} \ No newline at end of file +} diff --git a/src/app/components/event/event.card.component.spec.ts b/src/app/components/event/event.card.component.spec.ts index 8552c1898..322a2b328 100644 --- a/src/app/components/event/event.card.component.spec.ts +++ b/src/app/components/event/event.card.component.spec.ts @@ -2,7 +2,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EventCardComponent } from './event.card.component'; import { ActivatedRoute, Router } from '@angular/router'; -import { of } from 'rxjs'; +import { BehaviorSubject, of, Subject } from 'rxjs'; import { AppAuthService } from '../../authentication/app.auth.service'; import { AppUserService } from '../../services/app.user.service'; import { AppActivitySelectionService } from '../../services/activity-selection-service/app-activity-selection.service'; @@ -12,6 +12,16 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { EventInterface, User, ActivityInterface, ChartThemes, AppThemes, XAxisTypes } from '@sports-alliance/sports-lib'; import { LoggerService } from '../../services/logger.service'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; +import { shouldRenderIntensityZonesChart } from '../../helpers/intensity-zones-chart-data-helper'; +import { shouldRenderPowerCurveChart } from '../../helpers/power-curve-chart-data-helper'; +import { AppEventService } from '../../services/app.event.service'; + +vi.mock('../../helpers/intensity-zones-chart-data-helper', () => ({ + shouldRenderIntensityZonesChart: vi.fn(), +})); +vi.mock('../../helpers/power-curve-chart-data-helper', () => ({ + shouldRenderPowerCurveChart: vi.fn(), +})); describe('EventCardComponent', () => { let component: EventCardComponent; @@ -26,6 +36,13 @@ describe('EventCardComponent', () => { let mockRouter: any; let mockLoggerService: any; let mockBottomSheet: any; + let mockEventService: any; + let routeData$: BehaviorSubject<{ event: EventInterface }>; + let routeUserID: string; + let routeEventID: string; + let liveEventDetailsByRouteKey: Map>; + const mockedShouldRenderIntensityZonesChart = vi.mocked(shouldRenderIntensityZonesChart); + const mockedShouldRenderPowerCurveChart = vi.mocked(shouldRenderPowerCurveChart); const mockUser = new User('testUser'); mockUser.settings = { @@ -58,26 +75,46 @@ describe('EventCardComponent', () => { } } as any; - const mockActivity = { - getID: () => 'act1', - getLaps: () => [], - intensityZones: [], - creator: { devices: [], name: 'Test Device', swInfo: '' }, - hasPositionData: () => false - } as unknown as ActivityInterface; - - const mockEvent = { - getActivities: () => [mockActivity], - getID: () => 'evt1', + const createActivity = (id: string, hasData = false): ActivityInterface => ({ + getID: () => id, + getLaps: () => hasData ? [{ type: 'Manual' }] as any : [], + intensityZones: hasData ? [{ zone: 1 }] as any : [], + creator: hasData + ? { devices: [{ name: 'HRM' }], name: `Device ${id}`, swInfo: '' } + : { devices: [], name: `Device ${id}`, swInfo: '' }, + hasPositionData: () => hasData, + getStreams: () => [], + clearStreams: vi.fn(), + addStreams: vi.fn(), + } as unknown as ActivityInterface); + + const createEvent = (id: string, activities: ActivityInterface[], name = 'Event'): EventInterface => ({ + name, + getActivities: () => activities, + getID: () => id, isMerge: false - } as unknown as EventInterface; + } as unknown as EventInterface); + + const mockActivity = createActivity('act1'); + const mockEvent = createEvent('evt1', [mockActivity], 'Initial Event'); beforeEach(async () => { + mockedShouldRenderIntensityZonesChart.mockReturnValue(false); + mockedShouldRenderPowerCurveChart.mockReturnValue(false); + routeData$ = new BehaviorSubject({ event: mockEvent }); + routeUserID = 'testUser'; + routeEventID = 'evt1'; + liveEventDetailsByRouteKey = new Map(); + mockActivatedRoute = { - data: of({ event: mockEvent }), + data: routeData$, snapshot: { paramMap: { - get: (key: string) => (key === 'userID' ? 'testUser' : null) + get: (key: string) => { + if (key === 'userID') return routeUserID; + if (key === 'eventID') return routeEventID; + return null; + } } } }; @@ -105,6 +142,16 @@ describe('EventCardComponent', () => { mockSnackBar = { open: vi.fn() }; mockBottomSheet = { open: vi.fn() }; + mockEventService = { + getEventDetailsLive: vi.fn((_user: User, eventID: string) => { + const routeKey = `${_user.uid}:${eventID}`; + if (!liveEventDetailsByRouteKey.has(routeKey)) { + liveEventDetailsByRouteKey.set(routeKey, new Subject()); + } + return liveEventDetailsByRouteKey.get(routeKey)!.asObservable(); + }), + getEventActivitiesAndSomeStreams: vi.fn(() => of(mockEvent)), + }; mockThemeService = { getChartTheme: () => of(ChartThemes.Material), @@ -113,7 +160,7 @@ describe('EventCardComponent', () => { }; mockRouter = { navigate: vi.fn() }; - mockLoggerService = { log: vi.fn() }; + mockLoggerService = { log: vi.fn(), error: vi.fn() }; await TestBed.configureTestingModule({ declarations: [EventCardComponent], @@ -126,7 +173,8 @@ describe('EventCardComponent', () => { { provide: MatBottomSheet, useValue: mockBottomSheet }, { provide: AppThemeService, useValue: mockThemeService }, { provide: Router, useValue: mockRouter }, - { provide: LoggerService, useValue: mockLoggerService } + { provide: LoggerService, useValue: mockLoggerService }, + { provide: AppEventService, useValue: mockEventService }, ], schemas: [NO_ERRORS_SCHEMA] }) @@ -147,6 +195,86 @@ describe('EventCardComponent', () => { expect(component.event()).toBe(mockEvent); expect(mockActivitySelectionService.selectedActivities.clear).toHaveBeenCalled(); expect(mockActivitySelectionService.selectedActivities.select).toHaveBeenCalled(); + expect(mockEventService.getEventDetailsLive).toHaveBeenCalledTimes(1); + expect(mockedShouldRenderIntensityZonesChart).toHaveBeenCalled(); + expect(mockedShouldRenderPowerCurveChart).toHaveBeenCalled(); + }); + + it('should apply live event updates for matching activity IDs', () => { + const liveUpdatedEvent = createEvent('evt1', [createActivity('act1')], 'Live Updated Event'); + + liveEventDetailsByRouteKey.get('testUser:evt1')?.next(liveUpdatedEvent); + + expect(component.event()).toBe(liveUpdatedEvent); + expect(component.event()?.name).toBe('Live Updated Event'); + }); + + it('should trigger one full refresh when live activity IDs mismatch', async () => { + const liveMismatchedEvent = createEvent('evt1', [createActivity('act2')], 'Live Mismatch Event'); + const refreshedEvent = createEvent('evt1', [createActivity('act1')], 'Refreshed Event'); + mockEventService.getEventActivitiesAndSomeStreams.mockReturnValue(of(refreshedEvent)); + + liveEventDetailsByRouteKey.get('testUser:evt1')?.next(liveMismatchedEvent); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockEventService.getEventActivitiesAndSomeStreams).toHaveBeenCalledTimes(1); + expect(component.event()).toBe(refreshedEvent); + }); + + it('should preserve selected activity IDs on live updates', () => { + const activityA = createActivity('act-a'); + const activityB = createActivity('act-b'); + const initialEvent = createEvent('evt1', [activityA, activityB], 'Initial Multi Event'); + component.event.set(initialEvent as any); + component.selectedActivitiesInstant.set([activityB]); + component.selectedActivitiesDebounced.set([activityB]); + + const liveUpdatedEvent = createEvent('evt1', [createActivity('act-a'), createActivity('act-b')], 'Live Multi Event'); + liveEventDetailsByRouteKey.get('testUser:evt1')?.next(liveUpdatedEvent); + + expect(component.selectedActivitiesInstant().map((activity) => activity.getID())).toEqual(['act-b']); + }); + + it('should restart live sync when route eventID changes', () => { + const secondRouteEvent = createEvent('evt2', [createActivity('act-2')], 'Second Event'); + routeEventID = 'evt2'; + routeData$.next({ event: secondRouteEvent }); + + expect(mockEventService.getEventDetailsLive).toHaveBeenCalledTimes(2); + expect(mockEventService.getEventDetailsLive).toHaveBeenNthCalledWith(2, expect.any(User), 'evt2'); + + const staleEventUpdate = createEvent('evt1', [createActivity('act-1')], 'Stale Event Update'); + liveEventDetailsByRouteKey.get('testUser:evt1')?.next(staleEventUpdate); + expect(component.event()?.getID()).toBe('evt2'); + + const liveUpdatedSecondEvent = createEvent('evt2', [createActivity('act-2')], 'Live Updated Event 2'); + liveEventDetailsByRouteKey.get('testUser:evt2')?.next(liveUpdatedSecondEvent); + expect(component.event()).toBe(liveUpdatedSecondEvent); + }); + + it('should restart live sync when route userID changes for the same eventID', () => { + const sameEventDifferentUser = createEvent('evt1', [createActivity('act-shared')], 'Shared Event'); + routeUserID = 'otherUser'; + routeData$.next({ event: sameEventDifferentUser }); + + expect(mockEventService.getEventDetailsLive).toHaveBeenCalledTimes(2); + expect(mockEventService.getEventDetailsLive).toHaveBeenNthCalledWith(2, expect.any(User), 'evt1'); + + const staleOldUserUpdate = createEvent('evt1', [createActivity('act-legacy')], 'Old User Live Update'); + liveEventDetailsByRouteKey.get('testUser:evt1')?.next(staleOldUserUpdate); + expect(component.event()?.name).toBe('Shared Event'); + + const activeUserLiveUpdate = createEvent('evt1', [createActivity('act-shared')], 'New User Live Update'); + liveEventDetailsByRouteKey.get('otherUser:evt1')?.next(activeUserLiveUpdate); + expect(component.event()).toBe(activeUserLiveUpdate); + }); + + it('should not restart live sync when route data re-emits for the same userID and eventID', () => { + const duplicateEmissionEvent = createEvent('evt1', [createActivity('act1')], 'Duplicate Emission Event'); + routeData$.next({ event: duplicateEmissionEvent }); + + expect(mockEventService.getEventDetailsLive).toHaveBeenCalledTimes(1); }); it('should set targetUserID signal from route', () => { @@ -170,6 +298,19 @@ describe('EventCardComponent', () => { expect(component.hasIntensityZonesFlag()).toBe(false); }); + it('should compute hasIntensityZonesFlag as false when data collapses to one zone', () => { + mockedShouldRenderIntensityZonesChart.mockReturnValue(false); + expect(component.hasIntensityZonesFlag()).toBe(false); + }); + + it('should compute hasPowerCurveFlag as false when no power curve exists', () => { + expect(component.hasPowerCurveFlag()).toBe(false); + }); + + it('should compute hasPerformanceChartsFlag as false when intensity and power curve are both unavailable', () => { + expect(component.hasPerformanceChartsFlag()).toBe(false); + }); + it('should compute hasDevicesFlag as false when no devices', () => { expect(component.hasDevicesFlag()).toBe(false); }); @@ -198,8 +339,10 @@ describe('EventCardComponent', () => { } as unknown as EventInterface; beforeEach(() => { - // Update the route data mock - mockActivatedRoute.data = of({ event: eventWithData }); + mockedShouldRenderIntensityZonesChart.mockReturnValue(true); + mockedShouldRenderPowerCurveChart.mockReturnValue(true); + routeEventID = 'evt2'; + routeData$.next({ event: eventWithData }); // Recreate fixture with new data fixture = TestBed.createComponent(EventCardComponent); @@ -222,5 +365,14 @@ describe('EventCardComponent', () => { it('should compute hasPositionsFlag as true when position data exists', () => { expect(component.hasPositionsFlag()).toBe(true); }); + + it('should compute hasPowerCurveFlag as true when power curve exists', () => { + expect(component.hasPowerCurveFlag()).toBe(true); + }); + + it('should compute hasPerformanceChartsFlag as true when either chart is available', () => { + expect(component.hasPerformanceChartsFlag()).toBe(true); + }); }); + }); diff --git a/src/app/components/event/event.card.component.ts b/src/app/components/event/event.card.component.ts index 0eadf5216..8e3c0c3da 100644 --- a/src/app/components/event/event.card.component.ts +++ b/src/app/components/event/event.card.component.ts @@ -10,6 +10,7 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { debounceTime } from 'rxjs/operators'; +import { firstValueFrom, Subscription } from 'rxjs'; import { ActivityInterface } from '@sports-alliance/sports-lib'; import { AppEventInterface } from '../../../../functions/src/shared/app-event.interface'; @@ -22,6 +23,14 @@ import { ChartThemes, XAxisTypes } from '@sports-alliance/sports-lib'; +import { + DataDistance, + DataGradeAdjustedSpeed, + DataLatitudeDegrees, + DataLongitudeDegrees, + DataSpeed, + DynamicDataLoader +} from '@sports-alliance/sports-lib'; import { AppThemeService } from '../../services/app.theme.service'; import { AppThemes } from '@sports-alliance/sports-lib'; import { AppUserService } from '../../services/app.user.service'; @@ -30,12 +39,14 @@ import { AppUserSettingsQueryService } from '../../services/app.user-settings-qu import { LapTypes } from '@sports-alliance/sports-lib'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { LoggerService } from '../../services/logger.service'; - - +import { AppEventService } from '../../services/app.event.service'; +import { shouldRenderIntensityZonesChart } from '../../helpers/intensity-zones-chart-data-helper'; +import { shouldRenderPowerCurveChart } from '../../helpers/power-curve-chart-data-helper'; +import { reconcileEventDetailsLiveUpdate } from '../../utils/event-live-reconcile'; @Component({ selector: 'app-event-card', templateUrl: './event.card.component.html', - styleUrls: ['./event.card.component.css'], + styleUrls: ['./event.card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) @@ -52,6 +63,7 @@ export class EventCardComponent implements OnInit { private themeService = inject(AppThemeService); private bottomSheet = inject(MatBottomSheet); private logger = inject(LoggerService); + private eventService = inject(AppEventService); // Signal-based state public event = signal(null); @@ -60,6 +72,9 @@ export class EventCardComponent implements OnInit { public selectedActivitiesDebounced = signal([]); public isDownloading = signal(false); public targetUserID = signal(''); + private liveSyncSubscription: Subscription | null = null; + private liveSyncRouteKey: string | null = null; + private liveReloadInProgress = false; // Computed signals for template - replaces method calls public hasLapsFlag = computed(() => @@ -67,7 +82,15 @@ export class EventCardComponent implements OnInit { ); public hasIntensityZonesFlag = computed(() => - this.event()?.getActivities().some(a => a.intensityZones?.length > 0) ?? false + shouldRenderIntensityZonesChart(this.selectedActivitiesInstant()) + ); + + public hasPowerCurveFlag = computed(() => + shouldRenderPowerCurveChart(this.selectedActivitiesInstant()) + ); + + public hasPerformanceChartsFlag = computed(() => + this.hasIntensityZonesFlag() || this.hasPowerCurveFlag() ); public hasDevicesFlag = computed(() => @@ -109,6 +132,8 @@ export class EventCardComponent implements OnInit { ]; ngOnInit() { + this.logger.log('[EventCard] ngOnInit: initializing event details subscriptions'); + // Activity selection - debounced // Instant selection update this.activitySelectionService.selectedActivities.changed @@ -132,24 +157,39 @@ export class EventCardComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data: any) => { const resolvedData = data.event as any; + this.logger.log('[EventCard] route.data emission received', { + hasWrappedResolverData: !!(resolvedData && resolvedData.event), + hasResolvedData: !!resolvedData, + }); if (resolvedData && resolvedData.event) { this.event.set(resolvedData.event); this.currentUser.set(resolvedData.user); + this.logger.log('[EventCard] route.data applied wrapped resolver payload', { + eventID: resolvedData.event?.getID?.(), + activityCount: resolvedData.event?.getActivities?.()?.length ?? 0, + userID: resolvedData.user?.uid ?? null, + }); } else { this.event.set(resolvedData); + this.logger.log('[EventCard] route.data applied direct event payload', { + eventID: resolvedData?.getID?.(), + activityCount: resolvedData?.getActivities?.()?.length ?? 0, + }); } - - - this.activitySelectionService.selectedActivities.clear(); const activities = this.event()?.getActivities() ?? []; - this.activitySelectionService.selectedActivities.select(...activities); - // Initial set for both - this.selectedActivitiesInstant.set(activities); - this.selectedActivitiesDebounced.set(activities); + this.logger.log('[EventCard] syncing selected activities from route payload', { + eventID: this.event()?.getID?.() ?? null, + activityCount: activities.length, + }); + this.syncSelectedActivities(activities); this.targetUserID.set(this.route.snapshot.paramMap.get('userID') ?? ''); + this.logger.log('[EventCard] target user set from route snapshot', { + targetUserID: this.targetUserID(), + }); + this.startEventDetailsLiveSync(); }); // User auth subscription @@ -159,4 +199,211 @@ export class EventCardComponent implements OnInit { this.currentUser.set(user); }); } + + private startEventDetailsLiveSync(): void { + const eventID = this.route.snapshot.paramMap.get('eventID'); + const targetUserID = this.route.snapshot.paramMap.get('userID'); + this.logger.log('[EventCard] startEventDetailsLiveSync called', { eventID, targetUserID }); + if (!eventID || !targetUserID) { + this.liveSyncSubscription?.unsubscribe(); + this.liveSyncSubscription = null; + this.liveSyncRouteKey = null; + this.logger.log('[EventCard] live sync not started due to missing route params'); + return; + } + + const liveSyncRouteKey = `${targetUserID}:${eventID}`; + if ( + this.liveSyncRouteKey === liveSyncRouteKey + && this.liveSyncSubscription + && !this.liveSyncSubscription.closed + ) { + this.logger.log('[EventCard] live sync already active for route key', { liveSyncRouteKey }); + return; + } + + this.logger.log('[EventCard] starting live sync subscription', { liveSyncRouteKey }); + this.liveSyncSubscription?.unsubscribe(); + this.liveSyncRouteKey = liveSyncRouteKey; + this.liveSyncSubscription = this.eventService.getEventDetailsLive(new User(targetUserID), eventID) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (liveEvent) => { + this.logger.log('[EventCard] live sync emission received', { + eventID: liveEvent?.getID?.() ?? null, + activityCount: liveEvent?.getActivities?.()?.length ?? 0, + }); + this.applyLiveEventUpdate(liveEvent); + }, + error: (error) => { + this.logger.error('Live event details sync failed', error); + this.snackBar.open('Could not live-sync event details', undefined, { duration: 3000 }); + this.router.navigate(['/dashboard']); + }, + }); + } + + private applyLiveEventUpdate(liveEvent: AppEventInterface | null): void { + if (!liveEvent) { + this.logger.log('[EventCard] applyLiveEventUpdate skipped because liveEvent is null'); + return; + } + const selectedIDs = this.selectedActivitiesInstant().map((activity) => activity.getID()); + const reconcileResult = reconcileEventDetailsLiveUpdate(this.event(), liveEvent, selectedIDs); + this.logger.log('[EventCard] reconcile result for live update', { + incomingEventID: liveEvent.getID(), + incomingActivityCount: liveEvent.getActivities().length, + selectedIDs, + nextSelectedIDs: reconcileResult.selectedActivityIDs, + needsFullReload: reconcileResult.needsFullReload, + }); + + if (reconcileResult.needsFullReload) { + this.logger.log('[EventCard] full reload required after live reconcile'); + this.reloadEventDetailsWithStreams(); + return; + } + + this.event.set(reconcileResult.reconciledEvent); + this.applySelectedActivityIDs(reconcileResult.selectedActivityIDs, true); + } + + private async reloadEventDetailsWithStreams(): Promise { + if (this.liveReloadInProgress) { + this.logger.log('[EventCard] reloadEventDetailsWithStreams skipped (already in progress)'); + return; + } + const eventID = this.route.snapshot.paramMap.get('eventID'); + const targetUserID = this.route.snapshot.paramMap.get('userID'); + if (!eventID || !targetUserID) { + this.logger.log('[EventCard] reloadEventDetailsWithStreams skipped due to missing route params', { eventID, targetUserID }); + return; + } + + const previousSelectedIDs = this.selectedActivitiesInstant().map((activity) => activity.getID()); + const streamTypes = this.getLiveStreamTypes(); + this.logger.log('[EventCard] reloadEventDetailsWithStreams started', { + eventID, + targetUserID, + previousSelectedIDs, + streamTypes, + }); + this.liveReloadInProgress = true; + try { + const refreshedEvent = await firstValueFrom( + this.eventService.getEventActivitiesAndSomeStreams( + new User(targetUserID), + eventID, + streamTypes, + ), + ); + if (!refreshedEvent) { + this.logger.log('[EventCard] reloadEventDetailsWithStreams completed with null refreshedEvent'); + return; + } + + this.event.set(refreshedEvent as AppEventInterface); + const refreshedActivityIDs = (refreshedEvent.getActivities() || []).map((activity) => activity.getID()); + const refreshedIDSet = new Set(refreshedActivityIDs); + const preservedIDs = previousSelectedIDs.filter((activityID) => refreshedIDSet.has(activityID)); + const nextSelectedIDs = preservedIDs.length || previousSelectedIDs.length === 0 + ? preservedIDs + : refreshedActivityIDs; + this.logger.log('[EventCard] reloadEventDetailsWithStreams refreshed event applied', { + refreshedEventID: refreshedEvent.getID(), + refreshedActivityIDs, + nextSelectedIDs, + }); + + this.applySelectedActivityIDs(nextSelectedIDs, true); + } catch (error) { + this.logger.error('Could not refresh event details after live reconcile mismatch', error); + this.snackBar.open('Could not refresh event details', undefined, { duration: 3000 }); + this.router.navigate(['/dashboard']); + } finally { + this.liveReloadInProgress = false; + } + } + + private applySelectedActivityIDs(selectedActivityIDs: string[], forceRebind: boolean = false): void { + const selectedSet = new Set(selectedActivityIDs); + const activities = this.event()?.getActivities() ?? []; + const selectedActivities = activities.filter((activity) => selectedSet.has(activity.getID())); + + if (!forceRebind && this.hasSameSelectedActivities(selectedActivities)) { + return; + } + + this.syncSelectedActivities(selectedActivities); + } + + private syncSelectedActivities(selectedActivities: ActivityInterface[]): void { + this.logger.log('[EventCard] syncSelectedActivities called', { + incomingSelectionCount: selectedActivities.length, + incomingSelectionIDs: selectedActivities.map((activity) => activity.getID()), + }); + const nextSelection = [...selectedActivities]; + this.activitySelectionService.selectedActivities.clear(false); + if (nextSelection.length > 0) { + this.activitySelectionService.selectedActivities.select(...nextSelection); + } + + this.selectedActivitiesInstant.set(nextSelection); + this.selectedActivitiesDebounced.set(nextSelection); + } + + private hasSameSelectedActivities(nextActivities: ActivityInterface[]): boolean { + const currentActivities = this.selectedActivitiesInstant(); + if (currentActivities.length !== nextActivities.length) { + return false; + } + + for (let index = 0; index < currentActivities.length; index++) { + const current = currentActivities[index]; + const next = nextActivities[index]; + const currentID = current?.getID?.(); + const nextID = next?.getID?.(); + + if (currentID && nextID) { + if (currentID !== nextID) { + return false; + } + continue; + } + + if (current !== next) { + return false; + } + } + + return true; + } + + private getLiveStreamTypes(): string[] { + const streamTypes = [ + DataLatitudeDegrees.type, + DataLongitudeDegrees.type, + DataSpeed.type, + DataGradeAdjustedSpeed.type, + DataDistance.type, + ]; + + const user = this.currentUser(); + if (user) { + const userChartDataTypes = this.userService.getUserChartDataTypesToUse(user); + const nonUnitBasedDataTypes = DynamicDataLoader.getNonUnitBasedDataTypes( + user.settings.chartSettings.showAllData, + userChartDataTypes, + ); + nonUnitBasedDataTypes.forEach((type) => { + if (!streamTypes.includes(type)) { + streamTypes.push(type); + } + }); + } + + this.logger.log('[EventCard] computed live stream types', { streamTypes }); + return streamTypes; + } + } diff --git a/src/app/components/event/intensity-zones/event.intensity-zones.component.css b/src/app/components/event/intensity-zones/event.intensity-zones.component.css index fc217047c..39a96db66 100644 --- a/src/app/components/event/intensity-zones/event.intensity-zones.component.css +++ b/src/app/components/event/intensity-zones/event.intensity-zones.component.css @@ -1,7 +1,14 @@ .intensity-zones-container { + display: flex; + flex-direction: column; padding-top: 0; padding-bottom: 0; margin: 0; - height: 26vh; + height: var(--performance-chart-height, 30vh); + min-height: var(--performance-chart-min-height, 128px); background: transparent; -} \ No newline at end of file +} + +.intensity-zones-chart { + flex: 1 1 auto; +} diff --git a/src/app/components/event/intensity-zones/event.intensity-zones.component.html b/src/app/components/event/intensity-zones/event.intensity-zones.component.html index c2c1b2682..ec251e7e7 100644 --- a/src/app/components/event/intensity-zones/event.intensity-zones.component.html +++ b/src/app/components/event/intensity-zones/event.intensity-zones.component.html @@ -1,3 +1,3 @@
-
-
\ No newline at end of file +
+
diff --git a/src/app/components/event/intensity-zones/event.intensity-zones.component.spec.ts b/src/app/components/event/intensity-zones/event.intensity-zones.component.spec.ts new file mode 100644 index 000000000..ac6f21226 --- /dev/null +++ b/src/app/components/event/intensity-zones/event.intensity-zones.component.spec.ts @@ -0,0 +1,482 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SimpleChange } from '@angular/core'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { Subject } from 'rxjs'; +import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest'; +import { ChartThemes } from '@sports-alliance/sports-lib'; + +import { EventIntensityZonesComponent } from './event.intensity-zones.component'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; +import { AppEventColorService } from '../../../services/color/app.event.color.service'; +import { LoggerService } from '../../../services/logger.service'; +import { convertIntensityZonesStatsToEchartsData } from '../../../helpers/intensity-zones-chart-data-helper'; + +vi.mock('../../../helpers/intensity-zones-chart-data-helper', () => ({ + convertIntensityZonesStatsToEchartsData: vi.fn(), +})); + +type ResizeObserverRecord = { + observe: ReturnType; + disconnect: ReturnType; + trigger: () => void; +}; + +describe('EventIntensityZonesComponent', () => { + let fixture: ComponentFixture; + let component: EventIntensityZonesComponent; + let breakpointSubject: Subject<{ matches: boolean }>; + let resizeObserverRecords: ResizeObserverRecord[]; + let originalResizeObserver: typeof ResizeObserver | undefined; + let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined; + let originalCancelAnimationFrame: typeof cancelAnimationFrame | undefined; + let requestAnimationFrameMock: ReturnType; + + let mockLoader: { + init: ReturnType; + setOption: ReturnType; + resize: ReturnType; + dispose: ReturnType; + }; + + let mockColorService: { + getColorForZoneHex: ReturnType; + }; + + let mockLogger: { + error: ReturnType; + }; + + const mockedConvert = vi.mocked(convertIntensityZonesStatsToEchartsData); + const mockChart = { + isDisposed: vi.fn().mockReturnValue(false), + }; + + const getLastOption = (): Record => { + return mockLoader.setOption.mock.calls.at(-1)?.[1] as Record; + }; + + beforeEach(async () => { + breakpointSubject = new Subject<{ matches: boolean }>(); + resizeObserverRecords = []; + originalResizeObserver = globalThis.ResizeObserver; + originalRequestAnimationFrame = globalThis.requestAnimationFrame; + originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + + requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + globalThis.requestAnimationFrame = requestAnimationFrameMock as unknown as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = vi.fn(); + + class ResizeObserverMock { + public observe = vi.fn(); + public disconnect = vi.fn(); + + constructor(private callback: ResizeObserverCallback) { + resizeObserverRecords.push({ + observe: this.observe, + disconnect: this.disconnect, + trigger: () => this.callback([], this as unknown as ResizeObserver), + }); + } + } + + globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + + mockLoader = { + init: vi.fn().mockResolvedValue(mockChart), + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + }; + + mockColorService = { + getColorForZoneHex: vi.fn().mockReturnValue('#16B4EA'), + }; + + mockLogger = { + error: vi.fn(), + }; + + mockedConvert.mockReturnValue({ + zones: ['Zone 1', 'Zone 2'], + series: [ + { + type: 'Heart Rate', + values: [100, 200], + percentages: [33.3333, 66.6666], + }, + ], + }); + + await TestBed.configureTestingModule({ + declarations: [EventIntensityZonesComponent], + providers: [ + { + provide: BreakpointObserver, + useValue: { + observe: vi.fn().mockReturnValue(breakpointSubject.asObservable()), + }, + }, + { provide: EChartsLoaderService, useValue: mockLoader }, + { provide: AppEventColorService, useValue: mockColorService }, + { provide: LoggerService, useValue: mockLogger }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EventIntensityZonesComponent); + component = fixture.componentInstance; + component.activities = []; + component.chartTheme = ChartThemes.Material; + component.useAnimations = false; + }); + + afterEach(() => { + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + if (originalRequestAnimationFrame) { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + } else { + delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame }).requestAnimationFrame; + } + if (originalCancelAnimationFrame) { + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } else { + delete (globalThis as { cancelAnimationFrame?: typeof cancelAnimationFrame }).cancelAnimationFrame; + } + + document.body.classList.remove('dark-theme'); + }); + + it('should initialize ECharts and render options once view is ready', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + + expect(mockLoader.init).toHaveBeenCalledTimes(1); + expect(mockLoader.setOption).toHaveBeenCalledTimes(1); + expect(mockLoader.resize).toHaveBeenCalledTimes(1); + expect(mockedConvert).toHaveBeenCalledWith(component.activities, false); + expect(option.grid.left).toBe(0); + expect(option.grid.right).toBe(0); + expect(option.grid.top).toBe(0); + expect(option.grid.bottom).toBe(0); + expect(option.series[0].clip).toBe(false); + expect(option.series[0].label.position).toBe('right'); + expect(option.series[0].label.align).toBe('left'); + expect(option.yAxis.splitArea.show).toBe(true); + expect(option.yAxis.splitArea.areaStyle.color).toEqual([ + 'rgba(22, 180, 234, 0.12)', + 'rgba(22, 180, 234, 0.12)', + ]); + expect(option.legend.show).toBe(false); + expect(fixture.nativeElement.querySelector('.intensity-zones-helper-text')).toBeNull(); + }); + + it('should ignore ngOnChanges before chart initialization', () => { + component.ngOnChanges({ + activities: new SimpleChange([], [{}], false), + }); + + expect(mockLoader.setOption).not.toHaveBeenCalled(); + }); + + it('should refresh chart for activities, theme, and animation input changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + component.ngOnChanges({ + activities: new SimpleChange([], [{}], false), + chartTheme: new SimpleChange(ChartThemes.Material, ChartThemes.Dark, false), + useAnimations: new SimpleChange(false, true, false), + }); + + expect(mockLoader.setOption).toHaveBeenCalledTimes(2); + }); + + it('should not refresh chart for unrelated input changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const callCountBefore = mockLoader.setOption.mock.calls.length; + + component.ngOnChanges({ + unknown: new SimpleChange(undefined, 123, false), + } as any); + + expect(mockLoader.setOption).toHaveBeenCalledTimes(callCountBefore); + }); + + it('should switch to short labels when xsmall breakpoint matches', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + breakpointSubject.next({ matches: true }); + + expect(mockedConvert).toHaveBeenLastCalledWith(component.activities, true); + expect(mockLoader.setOption).toHaveBeenCalledTimes(2); + const option = getLastOption(); + expect(option.grid.right).toBe(16); + expect(option.grid.bottom).toBe(0); + }); + + it('should apply dark theme styles when chartTheme is dark', async () => { + component.chartTheme = ChartThemes.Dark; + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + expect(option.tooltip?.backgroundColor).toBe('#303030'); + expect(option.legend?.textStyle?.color).toBe('#ffffff'); + expect(option.yAxis.splitArea.areaStyle.color).toEqual([ + 'rgba(22, 180, 234, 0.18)', + 'rgba(22, 180, 234, 0.18)', + ]); + }); + + it('should apply dark theme styles from body class even with light chartTheme', async () => { + document.body.classList.add('dark-theme'); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + expect(option.tooltip?.backgroundColor).toBe('#303030'); + expect(option.yAxis?.axisLabel?.color).toBe('#ffffff'); + }); + + it('should include zone rich styles from color service', async () => { + mockedConvert.mockReturnValue({ + zones: ['Zone 1', 'Zone 2', 'Zone 3'], + series: [ + { + type: 'Heart Rate', + values: [30, 20, 10], + percentages: [50, 33.3333, 16.6667], + }, + ], + }); + + mockColorService.getColorForZoneHex.mockImplementation((zone: string) => { + return `color-${zone}`; + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + + expect(mockColorService.getColorForZoneHex).toHaveBeenCalledWith('Zone 1'); + expect(mockColorService.getColorForZoneHex).toHaveBeenCalledWith('Zone 2'); + expect(mockColorService.getColorForZoneHex).toHaveBeenCalledWith('Zone 3'); + expect(option.yAxis.axisLabel.rich.zone_0.backgroundColor).toBe('color-Zone 1'); + expect(option.yAxis.axisLabel.rich.zone_0.align).toBe('center'); + expect(option.yAxis.axisLabel.rich.zone_0.verticalAlign).toBe('middle'); + expect(option.yAxis.axisLabel.rich.zone_0.width).toBe(56); + expect(option.series[0].label.rich.zone_0.width).toBe(22); + expect(option.series[0].label.rich.zone_2.backgroundColor).toBe('color-Zone 3'); + }); + + it('should hide labels for near-zero values', async () => { + mockedConvert.mockReturnValue({ + zones: ['Zone 1', 'Zone 2'], + series: [ + { + type: 'Heart Rate', + values: [120, 0.05], + percentages: [99.9, 0.1], + }, + ], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + const formatter = option.series[0].label.formatter as (params: { dataIndex: number }) => string; + + expect(formatter({ dataIndex: 1 })).toBe(''); + expect(formatter({ dataIndex: 0 })).toBe('{zone_0|100%}'); + }); + + it('should format tooltip content with zone, series, percentage, and duration', async () => { + mockedConvert.mockReturnValue({ + zones: ['Zone 1', 'Zone 2'], + series: [ + { + type: 'Heart Rate', + values: [120, 120], + percentages: [50, 50], + }, + ], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + const formatter = option.tooltip.formatter as (params: { dataIndex: number; seriesIndex: number; marker: string }) => string; + + const formatted = formatter({ dataIndex: 0, seriesIndex: 0, marker: '• ' }); + + expect(formatted).toContain('Zone 1'); + expect(formatted).toContain('Heart Rate'); + expect(formatted).toContain('50%'); + expect(formatted).toContain('Time: '); + expect(formatter({ dataIndex: 99, seriesIndex: 0, marker: '' })).toBe(''); + }); + + it('should use compact legend labels for common metrics', async () => { + mockedConvert.mockReturnValue({ + zones: ['Zone 1'], + series: [ + { type: 'Heart Rate', values: [100], percentages: [50] }, + { type: 'Power', values: [100], percentages: [50] }, + { type: 'Speed', values: [100], percentages: [50] }, + ], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + expect(option.series[0].name).toBe('HR'); + expect(option.series[1].name).toBe('PWR'); + expect(option.series[2].name).toBe('SPD'); + }); + + it('should use consistent percentage rounding between labels and tooltip', async () => { + mockedConvert.mockReturnValue({ + zones: ['Zone 1'], + series: [ + { + type: 'Heart Rate', + values: [120], + percentages: [49.6], + }, + ], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + const labelFormatter = option.series[0].label.formatter as (params: { dataIndex: number }) => string; + const tooltipFormatter = option.tooltip.formatter as (params: { + dataIndex: number; + seriesIndex: number; + marker: string; + }) => string; + + const labelText = labelFormatter({ dataIndex: 0 }); + const tooltipText = tooltipFormatter({ dataIndex: 0, seriesIndex: 0, marker: '' }); + + expect(labelText).toContain('50%'); + expect(labelText).not.toContain('m'); + expect(tooltipText).toContain('50%'); + }); + + it('should handle empty converted data gracefully', async () => { + mockedConvert.mockReturnValue({ + zones: [], + series: [], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const option = getLastOption(); + + expect(option.yAxis?.data).toEqual([]); + expect(option.series).toEqual([]); + }); + + it('should observe container resize and call chart resize', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(resizeObserverRecords).toHaveLength(1); + const baselineResizeCalls = mockLoader.resize.mock.calls.length; + const baselineRafCalls = requestAnimationFrameMock.mock.calls.length; + + const observer = resizeObserverRecords[0]; + expect(observer.observe).toHaveBeenCalledWith(component.chartDiv.nativeElement); + + observer.trigger(); + + expect(requestAnimationFrameMock.mock.calls.length).toBeGreaterThanOrEqual(baselineRafCalls); + expect(mockLoader.resize.mock.calls.length).toBeGreaterThanOrEqual(baselineResizeCalls); + }); + + it('should throttle rapid resize observer callbacks to one resize per animation frame', async () => { + const rafCallbacks: FrameRequestCallback[] = []; + let rafHandle = 0; + + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + rafHandle += 1; + return rafHandle; + }) as typeof requestAnimationFrame; + + fixture.detectChanges(); + await fixture.whenStable(); + + const baselineResizeCalls = mockLoader.resize.mock.calls.length; + const observer = resizeObserverRecords[0]; + + observer.trigger(); + observer.trigger(); + observer.trigger(); + + expect(mockLoader.resize).toHaveBeenCalledTimes(baselineResizeCalls); + expect(rafCallbacks).toHaveLength(1); + + rafCallbacks[0](16); + + expect(mockLoader.resize).toHaveBeenCalledTimes(baselineResizeCalls + 1); + }); + + it('should skip ResizeObserver setup when API is unavailable', async () => { + delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(resizeObserverRecords).toHaveLength(0); + expect(mockLoader.setOption).toHaveBeenCalledTimes(1); + }); + + it('should log and skip rendering when chart init fails', async () => { + mockLoader.init.mockRejectedValueOnce(new Error('init failed')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[EventIntensityZonesComponent] Failed to initialize ECharts', + expect.any(Error) + ); + expect(mockLoader.setOption).not.toHaveBeenCalled(); + }); + + it('should disconnect observers and dispose chart on destroy', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const observer = resizeObserverRecords[0]; + const renderCallCount = mockLoader.setOption.mock.calls.length; + + component.ngOnDestroy(); + breakpointSubject.next({ matches: true }); + + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(mockLoader.dispose).toHaveBeenCalledWith(mockChart); + expect(mockLoader.setOption).toHaveBeenCalledTimes(renderCallCount); + }); +}); diff --git a/src/app/components/event/intensity-zones/event.intensity-zones.component.ts b/src/app/components/event/intensity-zones/event.intensity-zones.component.ts index 8027c9357..62ca19dd8 100644 --- a/src/app/components/event/intensity-zones/event.intensity-zones.component.ts +++ b/src/app/components/event/intensity-zones/event.intensity-zones.component.ts @@ -1,37 +1,37 @@ import { AfterViewInit, ChangeDetectionStrategy, - ChangeDetectorRef, Component, + ElementRef, Input, NgZone, OnChanges, OnDestroy, SimpleChanges, + ViewChild, } from '@angular/core'; - import { BreakpointObserver } from '@angular/cdk/layout'; -import { AmChartsService } from '../../../services/am-charts.service'; -import type * as am4core from '@amcharts/amcharts4/core'; -import type * as am4charts from '@amcharts/amcharts4/charts'; -import { firstValueFrom } from 'rxjs'; - +import { Subscription } from 'rxjs'; +import type { EChartsType } from 'echarts/core'; -import { ActivityInterface } from '@sports-alliance/sports-lib'; -import { ChartAbstractDirective } from '../../charts/chart-abstract.directive'; -import { DataHeartRate } from '@sports-alliance/sports-lib'; -import { DataPower } from '@sports-alliance/sports-lib'; -import { DataSpeed } from '@sports-alliance/sports-lib'; +import { + ActivityInterface, + ChartThemes, + DataDuration, + DataHeartRate, +} from '@sports-alliance/sports-lib'; +import { AppBreakpoints } from '../../../constants/breakpoints'; +import { AppDataColors } from '../../../services/color/app.data.colors'; import { AppColors } from '../../../services/color/app.colors'; -import { DynamicDataLoader } from '@sports-alliance/sports-lib'; import { AppEventColorService } from '../../../services/color/app.event.color.service'; -import { convertIntensityZonesStatsToChartData, getActiveDataTypes } from '../../../helpers/intensity-zones-chart-data-helper'; -import { AppDataColors } from '../../../services/color/app.data.colors'; -import { DataDuration } from '@sports-alliance/sports-lib'; +import { EChartsLoaderService } from '../../../services/echarts-loader.service'; import { LoggerService } from '../../../services/logger.service'; -import { AppBreakpoints } from '../../../constants/breakpoints'; -import { Subscription } from 'rxjs'; +import { + convertIntensityZonesStatsToEchartsData, + IntensityZonesEChartsData, +} from '../../../helpers/intensity-zones-chart-data-helper'; +type ChartOption = Parameters[0]; @Component({ selector: 'app-event-intensity-zones', @@ -40,224 +40,407 @@ import { Subscription } from 'rxjs'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) -export class EventIntensityZonesComponent extends ChartAbstractDirective implements AfterViewInit, OnChanges, OnDestroy { - @Input() activities!: ActivityInterface[]; +export class EventIntensityZonesComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input() activities: ActivityInterface[] = []; + @Input() chartTheme: ChartThemes = ChartThemes.Material; + @Input() useAnimations = false; - protected declare chart: am4charts.XYChart; - private core!: typeof am4core; - private charts!: typeof am4charts; + @ViewChild('chartDiv', { static: true }) chartDiv!: ElementRef; + + private chart: EChartsType | null = null; private isMobile = false; private breakpointSubscription: Subscription; + private resizeObserver: ResizeObserver | null = null; + private resizeFrameId: number | null = null; - private getData(): any[] { - return convertIntensityZonesStatsToChartData(this.activities, this.isMobile); - } - - - constructor(protected zone: NgZone, - changeDetector: ChangeDetectorRef, + constructor( + private breakpointObserver: BreakpointObserver, + private eChartsLoader: EChartsLoaderService, private eventColorService: AppEventColorService, - protected amChartsService: AmChartsService, - protected logger: LoggerService, - private breakpointObserver: BreakpointObserver) { - super(zone, changeDetector, amChartsService, logger); - - // Subscribe to mobile breakpoint + private logger: LoggerService, + private zone: NgZone + ) { this.breakpointSubscription = this.breakpointObserver .observe([AppBreakpoints.XSmall]) .subscribe(result => { const wasMobile = this.isMobile; this.isMobile = result.matches; - // Refresh chart data if breakpoint changed and chart exists if (this.chart && wasMobile !== this.isMobile) { - this.updateChart(this.getData()); + this.refreshChart(); } }); } - async ngOnChanges(changes: SimpleChanges): Promise { - if (this.chart) { - if (changes.chartTheme || changes.useAnimations) { - this.destroyChart(); - this.chart = await this.createChart(); - } - this.updateChart(this.getData()); + async ngAfterViewInit(): Promise { + await this.initializeChart(); + this.refreshChart(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.chart) { + return; + } + if (changes.activities || changes.chartTheme || changes.useAnimations) { + this.refreshChart(); } } - override ngOnDestroy(): void { - super.ngOnDestroy(); + ngOnDestroy(): void { if (this.breakpointSubscription) { this.breakpointSubscription.unsubscribe(); } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.resizeFrameId !== null && typeof cancelAnimationFrame !== 'undefined') { + cancelAnimationFrame(this.resizeFrameId); + this.resizeFrameId = null; + } + this.eChartsLoader.dispose(this.chart); + this.chart = null; } + private async initializeChart(): Promise { + if (!this.chartDiv?.nativeElement) { + return; + } - async ngAfterViewInit(): Promise { - this.chart = await this.createChart(); - this.updateChart(this.getData()); + try { + this.chart = await this.eChartsLoader.init(this.chartDiv.nativeElement); + this.setupResizeObserver(); + } catch (error) { + this.logger.error('[EventIntensityZonesComponent] Failed to initialize ECharts', error); + } } + private setupResizeObserver(): void { + if (typeof ResizeObserver === 'undefined' || !this.chartDiv?.nativeElement) { + return; + } - protected async createChart(): Promise { - const modules = await this.amChartsService.load(); - this.core = modules.core; - this.charts = modules.charts; - - const chart = await super.createChart(this.charts.XYChart) as am4charts.XYChart; - - // chart.exporting.menu = this.getExportingMenu(); - chart.hiddenState.properties.opacity = 0; - chart.padding(12, 0, 0, 0); - - // Legend - const legend = new this.charts.Legend(); - chart.legend = legend; - legend.parent = chart.plotContainer; - legend.background.fill = this.core.color('#000'); - - legend.background.fillOpacity = 0.00; - legend.width = 100; - legend.align = 'right'; - legend.valign = 'bottom'; - - // X Axis - const valueAxis = chart.xAxes.push(new this.charts.DurationAxis()); - - valueAxis.renderer.grid.template.disabled = true; - valueAxis.cursorTooltipEnabled = false; - valueAxis.renderer.labels.template.disabled = true; - valueAxis.extraMax = 0; - - // Y Axis - const categoryAxis = chart.yAxes.push(new this.charts.CategoryAxis()); - - // categoryAxis.renderer.grid.template.disabled = true; - categoryAxis.renderer.grid.template.location = 0; - categoryAxis.renderer.minGridDistance = 1; - // categoryAxis.renderer.grid.template.strokeWidth = 2; - // categoryAxis.renderer.grid.template.strokeOpacity = ; - categoryAxis.cursorTooltipEnabled = false; - categoryAxis.dataFields.category = 'zone'; - categoryAxis.renderer.labels.template.align = 'left'; - // categoryAxis.renderer.labels.template.fontWeight = 'bold'; - categoryAxis.renderer.cellStartLocation = 0.05; - categoryAxis.renderer.cellEndLocation = 0.95; - categoryAxis.renderer.grid.template.disabled = true - - // categoryAxis.renderer.grid.template.fillOpacity = 1; - // categoryAxis.renderer.grid.template.fill = am4core.color('FFFFFF'); - - categoryAxis.renderer.axisFills.template.disabled = false; - categoryAxis.renderer.axisFills.template.fillOpacity = 0.1; - categoryAxis.fillRule = (dataItem) => { - if (dataItem.axisFill) { - dataItem.axisFill.visible = true; - } - }; - categoryAxis.renderer.axisFills.template.adapter.add('fill', (fill, target) => { - const dataContext = target.dataItem?.dataContext as any; - return dataContext ? (this.eventColorService.getColorForZone(dataContext['zone']) as am4core.Color) : (fill as am4core.Color); + this.zone.runOutsideAngular(() => { + this.resizeObserver = new ResizeObserver(() => { + this.scheduleResize(); + }); + this.resizeObserver.observe(this.chartDiv.nativeElement); }); + } + private refreshChart(): void { + if (!this.chart) { + return; + } + const data = convertIntensityZonesStatsToEchartsData(this.activities, this.isMobile); + const option = this.buildChartOption(data); + this.eChartsLoader.setOption(this.chart, option, { notMerge: true, lazyUpdate: true }); + this.scheduleResize(); + } - return chart; + private buildChartOption(data: IntensityZonesEChartsData): ChartOption { + const darkTheme = this.isDarkThemeActive(); + const textColor = darkTheme ? '#ffffff' : '#2a2a2a'; + const gridLineColor = darkTheme ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'; + const zoneBackgroundOpacity = darkTheme ? 0.18 : 0.12; + const rightInset = this.isMobile ? 16 : 0; + const zoneAxisRichStyles = this.createZoneAxisRichStyles(data.zones); + const zoneBulletRichStyles = this.createZoneBulletRichStyles(data.zones); + const zoneBackgroundColors = data.zones.map(zone => + this.toTransparentColor(this.eventColorService.getColorForZoneHex(zone), zoneBackgroundOpacity) + ); + const series = data.series.map((seriesEntry, seriesIndex) => { + const displayName = this.getLegendLabel(seriesEntry.type); + return { + type: 'bar', + name: displayName, + data: seriesEntry.values, + barMaxWidth: 18, + clip: false, + itemStyle: { + color: this.getSeriesColor(seriesEntry.type), + borderRadius: [0, 8, 8, 0] + }, + label: { + show: true, + position: 'right', + distance: 4, + align: 'left', + color: textColor, + padding: [0, 2, 0, 2], + formatter: (params: { dataIndex: number }) => { + const dataIndex = params.dataIndex; + const value = seriesEntry.values[dataIndex] ?? 0; + if (value <= 0.1) { + return ''; + } + const percent = this.formatPercentage(seriesEntry.percentages[dataIndex] ?? 0); + return `{zone_${dataIndex}|${percent}%}`; + }, + rich: zoneBulletRichStyles + }, + emphasis: { + focus: 'none' + }, + tooltip: { + valueFormatter: (value: number) => new DataDuration(value).getDisplayValue() + }, + z: seriesIndex + 1 + }; + }); + + const option: ChartOption = { + animation: this.useAnimations === true, + textStyle: { + color: textColor, + fontFamily: "'Barlow Condensed', sans-serif" + }, + grid: { + left: 0, + right: rightInset, + top: 0, + bottom: 0, + containLabel: true + }, + legend: { + show: false, + selectedMode: true, + left: 'center', + bottom: 0, + orient: 'horizontal', + textStyle: { + color: textColor, + fontFamily: "'Barlow Condensed', sans-serif", + } + }, + tooltip: { + trigger: 'item', + backgroundColor: darkTheme ? '#303030' : '#ffffff', + borderColor: darkTheme ? '#6b6b6b' : '#d6d6d6', + textStyle: { + color: darkTheme ? '#ffffff' : '#2a2a2a', + fontFamily: "'Barlow Condensed', sans-serif", + }, + formatter: (params: { dataIndex: number; seriesIndex: number; marker: string }) => { + const dataIndex = params.dataIndex; + const seriesIndex = params.seriesIndex; + const zone = data.zones[dataIndex]; + const currentSeries = data.series[seriesIndex]; + if (!zone || !currentSeries) { + return ''; + } + + const value = currentSeries.values[dataIndex] ?? 0; + const percent = this.formatPercentage(currentSeries.percentages[dataIndex] ?? 0); + const duration = new DataDuration(value).getDisplayValue(); + return `${params.marker}${zone}
${currentSeries.type}: ${percent}%
Time: ${duration}`; + } + }, + xAxis: { + type: 'value', + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLine: { show: false } + }, + yAxis: { + type: 'category', + data: data.zones, + axisTick: { show: false }, + axisLine: { show: false }, + splitArea: { + show: true, + areaStyle: { + color: zoneBackgroundColors + } + }, + splitLine: { + show: true, + lineStyle: { + color: gridLineColor + } + }, + axisLabel: { + color: textColor, + fontFamily: "'Barlow Condensed', sans-serif", + formatter: (value: string) => { + const zoneIndex = data.zones.indexOf(value); + if (zoneIndex === -1) { + return value; + } + return `{zone_${zoneIndex}|${value}}`; + }, + rich: zoneAxisRichStyles + } + }, + series + }; + + return option; } - private updateChart(data: any) { - this.chart.series.clear(); - this.createChartSeries(getActiveDataTypes(data)); - this.chart.data = data + private createZoneAxisRichStyles(zones: string[]): Record { + const badgeWidth = this.isMobile ? 28 : 56; + const badgeLineHeight = this.isMobile ? 16 : 18; + + return zones.reduce((styles, zone, zoneIndex) => { + styles[`zone_${zoneIndex}`] = { + backgroundColor: this.eventColorService.getColorForZoneHex(zone), + borderRadius: 6, + color: '#ffffff', + fontWeight: 600, + width: badgeWidth, + align: 'center', + verticalAlign: 'middle', + lineHeight: badgeLineHeight, + padding: [1, 4, 1, 4], + }; + return styles; + }, {} as Record); } - private createChartSeries(activeTypes: Set) { - DynamicDataLoader.zoneStatsTypeMap.forEach(statsTypeMap => { - if (!activeTypes.has(statsTypeMap.type)) { + private createZoneBulletRichStyles(zones: string[]): Record { + const bulletWidth = this.isMobile ? 18 : 22; + const bulletLineHeight = this.isMobile ? 14 : 16; + + return zones.reduce((styles, zone, zoneIndex) => { + styles[`zone_${zoneIndex}`] = { + backgroundColor: this.eventColorService.getColorForZoneHex(zone), + borderRadius: 6, + color: '#ffffff', + fontWeight: 600, + width: bulletWidth, + align: 'center', + verticalAlign: 'middle', + lineHeight: bulletLineHeight, + padding: [0, 1, 0, 1], + }; + return styles; + }, {} as Record); + } + + private scheduleResize(): void { + if (!this.chart) { + return; + } + + if (typeof requestAnimationFrame === 'undefined') { + this.eChartsLoader.resize(this.chart); + return; + } + + if (this.resizeFrameId !== null) { + return; + } + + this.resizeFrameId = requestAnimationFrame(() => { + this.resizeFrameId = null; + if (!this.chart) { return; } - const series = this.chart.series.push(new this.charts.ColumnSeries()); - - // series.clustered = false; - series.dataFields.valueX = statsTypeMap.type; - series.dataFields.categoryY = 'zone'; - series.calculatePercent = true; - series.legendSettings.labelText = statsTypeMap.type === DataHeartRate.type ? 'HR' : `${statsTypeMap.type}`; - series.columns.template.tooltipText = `[bold font-size: 1.05em]{categoryY}[/]\n ${statsTypeMap.type}: [bold]{valueX.percent.formatNumber('#.')}%[/]\n Time: [bold]{valueX.formatDuration()}[/]`; - - series.adapter.add('tooltipText', (text, target) => { - const dataItem = target.tooltipDataItem; - if (!dataItem || !dataItem.values.valueX) { - return text; - } - const value = dataItem.values.valueX.value; - const percent = dataItem.values.valueX.percent; - const duration = new DataDuration(value).getDisplayValue(); - return `[bold font-size: 1.05em]{categoryY}[/]\n ${statsTypeMap.type}: [bold]${Math.round(percent)}%[/]\n Time: [bold]${duration}[/]`; - }); - series.columns.template.strokeWidth = 0; - series.columns.template.height = this.core.percent(80); + this.eChartsLoader.resize(this.chart); + }); + } - series.columns.template.column.cornerRadiusBottomRight = 8; - series.columns.template.column.cornerRadiusTopRight = 8; + private formatPercentage(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.round(Math.max(0, value)); + } - const categoryLabel = series.bullets.push(new this.charts.LabelBullet()); + private toTransparentColor(hex: string, alpha: number): string { + const normalized = `${hex}`.trim(); + const clampedAlpha = Math.max(0, Math.min(1, alpha)); + + const sixDigit = normalized.match(/^#([a-fA-F0-9]{6})$/); + if (sixDigit) { + const value = sixDigit[1]; + const red = parseInt(value.substring(0, 2), 16); + const green = parseInt(value.substring(2, 4), 16); + const blue = parseInt(value.substring(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`; + } - categoryLabel.label.adapter.add('text', (text, target) => { - if (!target.dataItem || !target.dataItem.values || !target.dataItem.values.valueX) { - return text; - } - const value = target.dataItem.values.valueX.value; - if (value < 0.1) { - return ''; - } - const duration = new DataDuration(value).getDisplayValue(); - const percent = Math.round(target.dataItem.values.valueX.percent); - return `[bold]${duration}[/] (${percent}%)`; - }); - // Hide bullet if value is near 0 - categoryLabel.adapter.add('visible', (visible, target) => { - const value = target.dataItem?.values?.valueX?.value; - return (value !== undefined && value > 0.1); - }); - categoryLabel.adapter.add('opacity', (opacity, target) => { - const value = target.dataItem?.values?.valueX?.value; - return (value !== undefined && value > 0.1) ? 1 : 0; - }); - // Also ensure the label itself is hidden - categoryLabel.label.adapter.add('visible', (visible, target) => { - const value = target.dataItem?.values?.valueX?.value; - return (value !== undefined && value > 0.1); - }); - categoryLabel.locationX = 0; - categoryLabel.label.horizontalCenter = 'left'; - categoryLabel.label.verticalCenter = 'middle'; - categoryLabel.label.textAlign = 'middle'; - categoryLabel.label.truncate = false; - categoryLabel.label.hideOversized = false; - categoryLabel.label.fontSize = '0.75em'; - categoryLabel.label.dx = 6; - categoryLabel.label.fill = this.core.color('#ffffff'); - categoryLabel.label.padding(0, 4, 0, 4); - - categoryLabel.label.background = new this.core.RoundedRectangle(); - categoryLabel.label.background.fillOpacity = 1; - categoryLabel.label.background.strokeOpacity = 1; - - categoryLabel.label.background.adapter.add('fill', (fill, target) => { - const dataContext = target.dataItem?.dataContext as any; - return dataContext ? (this.eventColorService.getColorForZone(dataContext['zone']) as am4core.Color) : (fill as am4core.Color); - }); - categoryLabel.label.background.adapter.add('stroke', (stroke, target) => { - const dataContext = target.dataItem?.dataContext as any; - return dataContext ? (this.eventColorService.getColorForZone(dataContext['zone']) as am4core.Color) : (stroke as am4core.Color); - }); - // ((categoryLabel.label.background)).cornerRadius(2, 2, 2, 2); + const threeDigit = normalized.match(/^#([a-fA-F0-9]{3})$/); + if (threeDigit) { + const value = threeDigit[1]; + const red = parseInt(`${value[0]}${value[0]}`, 16); + const green = parseInt(`${value[1]}${value[1]}`, 16); + const blue = parseInt(`${value[2]}${value[2]}`, 16); + return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`; + } + + return normalized; + } - // ((categoryLabel.label.background)).cornerRadius(2, 2, 2, 2); + private getLegendLabel(type: string): string { + const normalizedType = `${type}`.trim().toLowerCase(); + if (type === DataHeartRate.type || normalizedType === 'heart rate') { + return 'HR'; + } + if (normalizedType === 'power') { + return 'PWR'; + } + if (normalizedType === 'speed') { + return 'SPD'; + } + return type; + } - series.fill = this.core.color((AppDataColors as any)[statsTypeMap.type] || AppColors.Blue); + private getSeriesColor(type: string): string { + const colorMap = AppDataColors as unknown as Record; + return colorMap[type] ?? AppColors.Blue; + } - }); + private isDarkThemeActive(): boolean { + const chartTheme = `${this.chartTheme}`.toLowerCase(); + if (chartTheme === 'dark' || chartTheme === 'amchartsdark') { + return true; + } + if (typeof document === 'undefined') { + return false; + } + return document.body.classList.contains('dark-theme'); } } diff --git a/src/app/components/event/laps/event.card.laps.component.html b/src/app/components/event/laps/event.card.laps.component.html index da4ac3ee1..9f4e90d40 100644 --- a/src/app/components/event/laps/event.card.laps.component.html +++ b/src/app/components/event/laps/event.card.laps.component.html @@ -1,9 +1,4 @@ - -
- linear_scale -
- Laps -
+ @for (availableLapType of availableLapTypes; track availableLapType) { @@ -42,4 +37,4 @@ } - \ No newline at end of file + diff --git a/src/app/components/event/laps/event.card.laps.component.spec.ts b/src/app/components/event/laps/event.card.laps.component.spec.ts new file mode 100644 index 000000000..ca6137567 --- /dev/null +++ b/src/app/components/event/laps/event.card.laps.component.spec.ts @@ -0,0 +1,39 @@ +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { EventCardLapsComponent } from './event.card.laps.component'; +import { AppEventColorService } from '../../../services/color/app.event.color.service'; + +describe('EventCardLapsComponent', () => { + let component: EventCardLapsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EventCardLapsComponent], + providers: [ + { provide: AppEventColorService, useValue: {} }, + { provide: ChangeDetectorRef, useValue: { markForCheck: vi.fn(), detectChanges: vi.fn() } }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(EventCardLapsComponent); + component = fixture.componentInstance; + component.selectedActivities = [] as any; + component.unitSettings = {} as any; + component.event = { getActivities: () => [] } as any; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render shared section header', () => { + const header = fixture.nativeElement.querySelector('app-event-section-header'); + expect(header).toBeTruthy(); + expect(header.getAttribute('icon')).toBe('linear_scale'); + expect(header.getAttribute('title')).toBe('Laps'); + }); +}); diff --git a/src/app/components/event/map/event.card.map.component.css b/src/app/components/event/map/event.card.map.component.css index c0f8aec62..10678b30d 100644 --- a/src/app/components/event/map/event.card.map.component.css +++ b/src/app/components/event/map/event.card.map.component.css @@ -4,24 +4,6 @@ background: transparent; } -.map-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - gap: 16px; -} - -.map-header-avatar { - display: flex; - align-items: center; - justify-content: center; -} - -.map-header-actions { - flex: 1; -} - /* map-progress-bar removed, handled by app-loading-overlay */ google-map { @@ -81,4 +63,4 @@ mat-slide-toggle { font: var(--mat-sys-body-small); display: flex; justify-content: space-between; -} \ No newline at end of file +} diff --git a/src/app/components/event/map/event.card.map.component.html b/src/app/components/event/map/event.card.map.component.html index b89b8b1f9..7c0a740d1 100644 --- a/src/app/components/event/map/event.card.map.component.html +++ b/src/app/components/event/map/event.card.map.component.html @@ -1,15 +1,11 @@
-
-
- map -
-
- - -
-
- + + + + + @if (googleMap && event.getActivities().length > 1) { @for (activity of event.getActivities(); track activity.getID()) { @@ -19,8 +15,6 @@ } } - - @if (activitiesMapData.length === 0) {
@@ -86,11 +80,7 @@ } } @placeholder { -
- - -
+
}
-
\ No newline at end of file +
diff --git a/src/app/components/event/map/event.card.map.component.spec.ts b/src/app/components/event/map/event.card.map.component.spec.ts index 2ac52584c..939141c3e 100644 --- a/src/app/components/event/map/event.card.map.component.spec.ts +++ b/src/app/components/event/map/event.card.map.component.spec.ts @@ -10,7 +10,7 @@ import { AppEventService } from '../../../services/app.event.service'; import { AppUserService } from '../../../services/app.user.service'; import { AppActivityCursorService } from '../../../services/activity-cursor/app-activity-cursor.service'; import { AppThemeService } from '../../../services/app.theme.service'; -import { AppThemes } from '@sports-alliance/sports-lib'; +import { AppThemes, DynamicDataLoader } from '@sports-alliance/sports-lib'; import { AppUserSettingsQueryService } from '../../../services/app.user-settings-query.service'; import { MarkerFactoryService } from '../../../services/map/marker-factory.service'; import { signal } from '@angular/core'; @@ -26,6 +26,11 @@ describe('EventCardMapComponent', () => { let mockCursorService: any; let mockThemeService: any; + const makeStat = (value: string, unit = '') => ({ + getDisplayValue: () => value, + getDisplayUnit: () => unit + }); + beforeEach(async () => { const mockLoader = { importLibrary: vi.fn().mockResolvedValue({ @@ -75,7 +80,7 @@ describe('EventCardMapComponent', () => { provide: MarkerFactoryService, useValue: { createPinMarker: vi.fn(), - // Add other methods if needed, mostly createPinMarker/EventMarker/ClusterMarker + createJumpMarker: vi.fn().mockReturnValue(document.createElement('div')), } }, { provide: NgZone, useValue: new NgZone({ enableLongStackTrace: false }) }, @@ -136,5 +141,135 @@ describe('EventCardMapComponent', () => { expect(component.mapTypeId()).toBe('satellite'); }); + it('should use smallest jump marker bucket when hang time is missing', () => { + const markerFactory = TestBed.inject(MarkerFactoryService) as any; + const jump = { + jumpData: { + distance: makeStat('1', 'm'), + score: makeStat('1'), + } + } as any; + + (component as any).jumpHangTimeMin = 1; + (component as any).jumpHangTimeMax = 2; + + component.getJumpMarkerOptions(jump, '#ff0000'); + + expect(markerFactory.createJumpMarker).toHaveBeenCalledWith( + '#ff0000', + EventCardMapComponent.JUMP_MARKER_SIZE_BUCKETS[0] + ); + }); + + it('should use middle jump marker bucket when all hang times are identical', () => { + const markerFactory = TestBed.inject(MarkerFactoryService) as any; + const jump = { + jumpData: { + hang_time: { getValue: () => 1.5, getDisplayValue: () => '01.5s' }, + distance: makeStat('1', 'm'), + score: makeStat('1'), + } + } as any; + + (component as any).jumpHangTimeMin = 1.5; + (component as any).jumpHangTimeMax = 1.5; + + component.getJumpMarkerOptions(jump, '#00ff00'); + + expect(markerFactory.createJumpMarker).toHaveBeenCalledWith( + '#00ff00', + EventCardMapComponent.JUMP_MARKER_SIZE_BUCKETS[2] + ); + }); + + it('should use largest jump marker bucket for max hang time', () => { + const markerFactory = TestBed.inject(MarkerFactoryService) as any; + const jump = { + jumpData: { + hang_time: { getValue: () => 5, getDisplayValue: () => '05.0s' }, + distance: makeStat('1', 'm'), + score: makeStat('1'), + } + } as any; + + (component as any).jumpHangTimeMin = 1; + (component as any).jumpHangTimeMax = 5; + + component.getJumpMarkerOptions(jump, '#0000ff'); + + expect(markerFactory.createJumpMarker).toHaveBeenCalledWith( + '#0000ff', + EventCardMapComponent.JUMP_MARKER_SIZE_BUCKETS[4] + ); + }); + + it('should format hang time in marker title using display formatter with milliseconds', () => { + const getDisplayValue = vi.fn().mockReturnValue('01.3s'); + const jump = { + jumpData: { + hang_time: { + getValue: () => 1.3, + getDisplayValue + }, + distance: makeStat('3.2', 'm'), + score: makeStat('8.7'), + speed: makeStat('12.3', 'km/h'), + rotations: makeStat('1.1') + } + } as any; + + const options = component.getJumpMarkerOptions(jump, '#111111'); + + expect(getDisplayValue).toHaveBeenCalledWith(false, true, true); + expect(options.title).toContain('Hang Time: 01.3s'); + }); + + it('should format speed in marker title using unit-based conversion', () => { + const conversionSpy = vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance').mockReturnValue([{ + getDisplayValue: () => '15.4', + getDisplayUnit: () => 'km/h' + }] as any); + const jump = { + jumpData: { + hang_time: { getValue: () => 1.3, getDisplayValue: () => '01.3s' }, + distance: makeStat('3.2', 'm'), + score: makeStat('8.7'), + speed: { getDisplayValue: () => '9.6', getDisplayUnit: () => 'm/s' }, + rotations: makeStat('1.1') + } + } as any; + + const options = component.getJumpMarkerOptions(jump, '#222222'); + + expect(options.title).toContain('Speed: 15.4 km/h'); + conversionSpy.mockRestore(); + }); + + it('should format distance in marker title using unit-based conversion', () => { + const conversionSpy = vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance').mockImplementation((stat: any) => { + if (stat?.getDisplayUnit?.() === 'm') { + return [{ + getDisplayValue: () => '10.5', + getDisplayUnit: () => 'ft' + }] as any; + } + return [stat] as any; + }); + const jump = { + jumpData: { + hang_time: { getValue: () => 1.3, getDisplayValue: () => '01.3s' }, + distance: makeStat('3.2', 'm'), + score: makeStat('8.7'), + speed: makeStat('9.6', 'm/s'), + rotations: makeStat('1.1') + } + } as any; + + const options = component.getJumpMarkerOptions(jump, '#222222'); + + expect(options.title).toContain('Distance: 10.5 ft'); + conversionSpy.mockRestore(); + }); + }); diff --git a/src/app/components/event/map/event.card.map.component.ts b/src/app/components/event/map/event.card.map.component.ts index 8c73fe46e..a4a107d16 100644 --- a/src/app/components/event/map/event.card.map.component.ts +++ b/src/app/components/event/map/event.card.map.component.ts @@ -19,7 +19,7 @@ import { import { GoogleMap, MapInfoWindow, MapAdvancedMarker } from '@angular/google-maps'; import { throttleTime } from 'rxjs/operators'; import { AppEventColorService } from '../../../services/color/app.event.color.service'; -import { EventInterface, ActivityInterface, LapInterface, User, LapTypes, GeoLibAdapter, DataLatitudeDegrees, DataLongitudeDegrees, DataJumpEvent, DataEvent } from '@sports-alliance/sports-lib'; +import { EventInterface, ActivityInterface, LapInterface, User, LapTypes, GeoLibAdapter, DataLatitudeDegrees, DataLongitudeDegrees, DataJumpEvent, DataEvent, DynamicDataLoader, DataInterface } from '@sports-alliance/sports-lib'; import { AppEventService } from '../../../services/app.event.service'; import { Subject, Subscription, asyncScheduler } from 'rxjs'; import { AppUserService } from '../../../services/app.user.service'; @@ -40,6 +40,7 @@ import { MarkerFactoryService } from '../../../services/map/marker-factory.servi standalone: false }) export class EventCardMapComponent extends MapAbstractDirective implements OnChanges, OnInit, OnDestroy, AfterViewInit { + public static readonly JUMP_MARKER_SIZE_BUCKETS = [18, 22, 26, 30, 34] as const; @ViewChild(GoogleMap) googleMap!: GoogleMap; @Input() event!: EventInterface; @Input() targetUserID!: string; @@ -90,6 +91,7 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha controlSize: 32, disableDefaultUI: true, backgroundColor: 'transparent', + gestureHandling: 'cooperative', fullscreenControl: true, scaleControl: true, rotateControl: true, @@ -114,6 +116,8 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha private processSequence = 0; private previousState: any = {}; private pendingFitBoundsTimeout: ReturnType | null = null; + private jumpHangTimeMin: number | null = null; + private jumpHangTimeMax: number | null = null; public mapInstance = signal(undefined); public isMapVisible = signal(true); @@ -358,18 +362,23 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha getJumpMarkerOptions(jump: DataJumpEvent, color: string): google.maps.marker.AdvancedMarkerElementOptions { const data = jump.jumpData; - const format = (v: number | undefined) => v !== undefined ? Math.round(v * 10) / 10 : '-'; + const hangTimeDisplay = data.hang_time ? data.hang_time.getDisplayValue(false, true, true) : '-'; + const distanceDisplay = this.getJumpStatDisplay(data.distance); + const heightDisplay = this.getJumpStatDisplay(data.height); + const scoreDisplay = this.getJumpStatDisplay(data.score); + const speedDisplay = this.getJumpStatDisplay(data.speed); + const rotationsDisplay = this.getJumpStatDisplay(data.rotations); const stats = [ - `Distance: ${format(data.distance.getValue())} ${data.distance.getDisplayUnit()}`, - `Height: ${data.height ? `${format(data.height.getValue())} ${data.height.getDisplayUnit()}` : '-'}`, - `Score: ${format(data.score.getValue())}`, - `Hang Time: ${data.hang_time ? `${format(data.hang_time.getValue())}` : '-'}`, - `Speed: ${data.speed ? `${format(data.speed.getValue())} ${data.speed.getDisplayUnit()}` : '-'}`, - `Rotations: ${data.rotations ? `${format(data.rotations.getValue())}` : '-'}` + `Distance: ${distanceDisplay}`, + `Height: ${heightDisplay}`, + `Score: ${scoreDisplay}`, + `Hang Time: ${hangTimeDisplay}`, + `Speed: ${speedDisplay}`, + `Rotations: ${rotationsDisplay}` ].join('\n'); const options = { - content: this.markerFactory.createJumpMarker(color), + content: this.markerFactory.createJumpMarker(color, this.getJumpMarkerSize(jump)), title: `Jump Stats:\n${stats}`, zIndex: 150, gmpClickable: true @@ -470,6 +479,8 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha this.loading(); this.noMapData = false; this.activitiesMapData = []; + this.jumpHangTimeMin = null; + this.jumpHangTimeMax = null; if (!this.selectedActivities?.length) { this.noMapData = true; @@ -526,6 +537,16 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha }, []) }); }); + + // Treat "activities without any usable position samples" as no map data. + const hasRenderableMapData = this.activitiesMapData.some((data) => data.positions.length > 0); + if (!hasRenderableMapData) { + this.noMapData = true; + this.loaded(); + return; + } + + this.updateJumpHangTimeRange(); this.loaded(); if (shouldFitBounds) { @@ -549,6 +570,73 @@ export class EventCardMapComponent extends MapAbstractDirective implements OnCha } } + private updateJumpHangTimeRange() { + const hangTimes = this.activitiesMapData + .flatMap(data => data.jumps) + .map(jump => this.getJumpHangTime(jump.event)) + .filter((value): value is number => value !== null); + + if (!hangTimes.length) { + this.jumpHangTimeMin = null; + this.jumpHangTimeMax = null; + return; + } + + this.jumpHangTimeMin = Math.min(...hangTimes); + this.jumpHangTimeMax = Math.max(...hangTimes); + } + + private getJumpHangTime(jump: DataJumpEvent): number | null { + const raw = jump?.jumpData?.hang_time?.getValue(); + return typeof raw === 'number' && Number.isFinite(raw) ? raw : null; + } + + private getPreferredUnitStat(stat: DataInterface | null | undefined): DataInterface | null { + if (!stat) { + return null; + } + + try { + const convertedStats = DynamicDataLoader.getUnitBasedDataFromDataInstance( + stat, + this.userSettingsQuery.unitSettings() + ); + return convertedStats?.[0] ?? stat; + } catch { + return stat; + } + } + + private getJumpStatDisplay(stat: DataInterface | null | undefined): string { + const preferredStat = this.getPreferredUnitStat(stat); + if (!preferredStat) { + return '-'; + } + + return `${preferredStat.getDisplayValue()} ${preferredStat.getDisplayUnit()}`.trim(); + } + + private getJumpMarkerSize(jump: DataJumpEvent): number { + const buckets = EventCardMapComponent.JUMP_MARKER_SIZE_BUCKETS; + const hangTime = this.getJumpHangTime(jump); + + if (hangTime === null || this.jumpHangTimeMin === null || this.jumpHangTimeMax === null) { + return buckets[0]; + } + + if (this.jumpHangTimeMin === this.jumpHangTimeMax) { + return buckets[Math.floor(buckets.length / 2)]; + } + + const normalized = (hangTime - this.jumpHangTimeMin) / (this.jumpHangTimeMax - this.jumpHangTimeMin); + const bucketIndex = Math.min( + buckets.length - 1, + Math.max(0, Math.floor(normalized * buckets.length)) + ); + + return buckets[bucketIndex]; + } + private fitBoundsToActivities() { if (!this.googleMap?.googleMap || !this.activitiesMapData.length) { return; diff --git a/src/app/components/event/map/map-actions/map.actions.component.css b/src/app/components/event/map/map-actions/map.actions.component.css index 1630343e2..36af43c9d 100644 --- a/src/app/components/event/map/map-actions/map.actions.component.css +++ b/src/app/components/event/map/map-actions/map.actions.component.css @@ -1,5 +1,32 @@ -section { +:host { + display: flex; + justify-content: flex-end; +} + +section.map-actions { display: flex; align-items: center; gap: 8px; -} \ No newline at end of file + justify-content: flex-end; +} + +.layers-trigger { + min-width: 0; +} + +.layers-trigger mat-icon { + margin-right: 4px; +} + +.map-layer-toggle { + width: 100%; +} + +section[mat-menu-item] { + height: auto; + min-height: unset; +} + +section[mat-menu-item]:hover { + background: transparent; +} diff --git a/src/app/components/event/map/map-actions/map.actions.component.html b/src/app/components/event/map/map-actions/map.actions.component.html index 8a0a22e80..d13898031 100644 --- a/src/app/components/event/map/map-actions/map.actions.component.html +++ b/src/app/components/event/map/map-actions/map.actions.component.html @@ -1,13 +1,24 @@ -
- - +
+ + + +
+ + +
+ Show Laps - - + +
+
+ Show Arrows - - - -
\ No newline at end of file + +
+
diff --git a/src/app/components/event/map/map-actions/map.actions.component.spec.ts b/src/app/components/event/map/map-actions/map.actions.component.spec.ts new file mode 100644 index 000000000..05a1aa56d --- /dev/null +++ b/src/app/components/event/map/map-actions/map.actions.component.spec.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { User } from '@sports-alliance/sports-lib'; +import { vi } from 'vitest'; +import { MapActionsComponent } from './map.actions.component'; +import { AppUserService } from '../../../../services/app.user.service'; +import { AppAnalyticsService } from '../../../../services/app.analytics.service'; + +describe('MapActionsComponent', () => { + let component: MapActionsComponent; + let fixture: ComponentFixture; + + const userServiceMock = { + updateUserProperties: vi.fn().mockResolvedValue(true), + }; + + const analyticsServiceMock = { + logEvent: vi.fn(), + }; + + const userMock = { + settings: { + mapSettings: { + showLaps: false, + showArrows: false, + }, + }, + } as unknown as User; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MapActionsComponent], + imports: [ + BrowserAnimationsModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatSlideToggleModule, + MatTooltipModule, + ], + providers: [ + { provide: AppUserService, useValue: userServiceMock }, + { provide: AppAnalyticsService, useValue: analyticsServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MapActionsComponent); + component = fixture.componentInstance; + component.showLaps = false; + component.showArrows = false; + component.user = userMock; + fixture.detectChanges(); + vi.clearAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should use base menu panel class', () => { + const templatePath = resolve(process.cwd(), 'src/app/components/event/map/map-actions/map.actions.component.html'); + const template = readFileSync(templatePath, 'utf8'); + expect(template).toContain(''); + }); + + it('should toggle laps and persist settings', async () => { + const showLapsEmitSpy = vi.spyOn(component.showLapsChange, 'emit'); + const showArrowsEmitSpy = vi.spyOn(component.showArrowsChange, 'emit'); + + await component.onShowLapsToggle(true); + + expect(component.showLaps).toBe(true); + expect(showLapsEmitSpy).toHaveBeenCalledWith(true); + expect(showArrowsEmitSpy).toHaveBeenCalledWith(false); + expect(userMock.settings.mapSettings.showLaps).toBe(true); + expect(userServiceMock.updateUserProperties).toHaveBeenCalledWith(userMock, { settings: userMock.settings }); + expect(analyticsServiceMock.logEvent).toHaveBeenCalledWith('event_map_settings_change'); + }); + + it('should emit without persisting when no user is available', async () => { + component.user = null as unknown as User; + + const showLapsEmitSpy = vi.spyOn(component.showLapsChange, 'emit'); + const showArrowsEmitSpy = vi.spyOn(component.showArrowsChange, 'emit'); + + await component.onShowArrowsToggle(true); + + expect(component.showArrows).toBe(true); + expect(showLapsEmitSpy).toHaveBeenCalledWith(false); + expect(showArrowsEmitSpy).toHaveBeenCalledWith(true); + expect(userServiceMock.updateUserProperties).not.toHaveBeenCalled(); + expect(analyticsServiceMock.logEvent).toHaveBeenCalledWith('event_map_settings_change'); + }); +}); diff --git a/src/app/components/event/map/map-actions/map.actions.component.ts b/src/app/components/event/map/map-actions/map.actions.component.ts index c45e93c82..28f3ba43b 100644 --- a/src/app/components/event/map/map-actions/map.actions.component.ts +++ b/src/app/components/event/map/map-actions/map.actions.component.ts @@ -32,7 +32,17 @@ export class MapActionsComponent { private userService: AppUserService) { } - async checkBoxChanged(_event) { + async onShowLapsToggle(checked: boolean) { + this.showLaps = checked; + await this.checkBoxChanged(); + } + + async onShowArrowsToggle(checked: boolean) { + this.showArrows = checked; + await this.checkBoxChanged(); + } + + async checkBoxChanged() { this.showLapsChange.emit(this.showLaps); this.showArrowsChange.emit(this.showArrows); diff --git a/src/app/components/event/map/popups/jump-marker-popup/jump-marker-popup.component.html b/src/app/components/event/map/popups/jump-marker-popup/jump-marker-popup.component.html index c0a729d34..58d5a496e 100644 --- a/src/app/components/event/map/popups/jump-marker-popup/jump-marker-popup.component.html +++ b/src/app/components/event/map/popups/jump-marker-popup/jump-marker-popup.component.html @@ -1,4 +1,4 @@ - } - - - + + diff --git a/src/app/components/user-settings/user-settings.component.scss b/src/app/components/user-settings/user-settings.component.scss index 78e0f91d1..7418f4976 100644 --- a/src/app/components/user-settings/user-settings.component.scss +++ b/src/app/components/user-settings/user-settings.component.scss @@ -1,3 +1,5 @@ +@use '../../../styles/breakpoints' as bp; + :host { // Removed local vars that override global theme --section-spacing: 24px; @@ -84,7 +86,7 @@ } } -@media (max-width: 600px) { +@include bp.xsmall { .user-profile-header { .header-content { flex-direction: column; @@ -107,112 +109,29 @@ .settings-container { display: block; - min-height: calc(100vh - 64px); + min-height: calc(100vh - var(--qs-effective-top-offset, 0px)); // background-color: #f4f7f6; /* REMOVED: Managed by global theme */ position: relative; } +@supports (height: 100dvh) { + .settings-container { + min-height: calc(100dvh - var(--qs-effective-top-offset, 0px)); + } +} + /* Horizontal Chip Navigation */ .settings-nav { - position: sticky; - top: 0; - z-index: 100; - // background-color: rgba(244, 247, 246, 0.95); /* REMOVED */ - background: inherit; - /* Allow clear background or global surface */ - backdrop-filter: blur(10px); - padding: 16px 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - /* Consider theme var for border? */ margin-bottom: 0; -} - -// Dark theme adjustment for nav border -:host-context(.dark-theme) .settings-nav { - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -.nav-list { - display: flex; - gap: 12px; - list-style: none; - padding: 12px 40px; - /* Aligns with content padding */ - margin: 0 auto; - margin: 0 auto; - overflow-x: auto; - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE/Edge */ -} - -.nav-list::-webkit-scrollbar { - display: none; - /* Chrome/Safari */ -} - -.nav-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - border-radius: 20px; - font-weight: 400; - font-size: 0.95rem; - // color: var(--text-secondary); /* REMOVED */ - opacity: 0.7; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - // background-color: white; /* REMOVED */ - background-color: rgba(0, 0, 0, 0.05); - /* Safe mild grey */ - border: 1px solid rgba(0, 0, 0, 0.08); - /* REMOVED or adapted */ - white-space: nowrap; -} - -:host-context(.dark-theme) .nav-item { - background-color: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); -} - -.nav-item mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - margin-right: 4px; -} - -.nav-item:hover { - // background-color: #f0f0f0; - background-color: rgba(0, 0, 0, 0.1); - // color: var(--text-primary); - opacity: 1; - transform: translateY(-1px); -} - -:host-context(.dark-theme) .nav-item:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.nav-item.active { - // background-color: var(--accent-color); - background-color: var(--mat-sys-primary, #3f51b5); // Use material primary - color: var(--mat-sys-on-primary, white); - border-color: transparent; - // box-shadow: 0 4px 12px rgba(63, 81, 181, 0.2); - opacity: 1; -} - -.nav-item.active mat-icon { - color: var(--mat-sys-on-primary, white); + display: block; + padding: 0 40px 12px; + border-radius: 0; } /* Mobile Responsive Adjustments */ -@media (max-width: 600px) { - .nav-list { +@include bp.xsmall { + .settings-nav { padding: 0 16px; } @@ -267,6 +186,18 @@ gap: 20px; } +.brand-text-upgrade { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + color: var(--mat-sys-on-surface-variant); + + mat-icon { + color: var(--mat-sys-primary); + } +} + .danger-card { // border: 1px solid #ffccd2; /* Red-ish border */ border: 1px solid rgba(255, 0, 0, 0.2); @@ -286,7 +217,7 @@ } /* Responsive */ -@media (max-width: 768px) { +@include bp.max-768 { .settings-container { flex-direction: column; } @@ -304,23 +235,10 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.1); } - .nav-list { - display: flex; - overflow-x: auto; + .settings-nav { padding: 0 16px; } - .nav-item { - padding: 12px 16px; - white-space: nowrap; - border-right: none; - } - - .nav-item.active { - border-right: none; - border-bottom: 3px solid var(--mat-sys-primary, #3f51b5); - } - .settings-content { padding: 24px 16px; } @@ -357,7 +275,7 @@ width: 100%; } -@media (max-width: 768px) { +@include bp.max-768 { .qs-form-actions-floating { display: none; } @@ -377,4 +295,4 @@ opacity: 0.5; filter: grayscale(1); pointer-events: none; -} \ No newline at end of file +} diff --git a/src/app/components/user-settings/user-settings.component.spec.ts b/src/app/components/user-settings/user-settings.component.spec.ts index c32e2c409..2fd9e33cf 100644 --- a/src/app/components/user-settings/user-settings.component.spec.ts +++ b/src/app/components/user-settings/user-settings.component.spec.ts @@ -109,6 +109,26 @@ describe('UserSettingsComponent', () => { expect(component).toBeTruthy(); }); + it('should map section id to tab index and back', () => { + expect(component.sectionIdToIndex('profile')).toBe(0); + expect(component.sectionIdToIndex('units')).toBe(5); + expect(component.indexToSectionId(1)).toBe('app'); + expect(component.indexToSectionId(99)).toBe('profile'); + }); + + it('should update active section when selected tab index changes', () => { + component.activeSection = 'profile'; + component.onSelectedSectionIndexChange(3); + expect(component.activeSection).toBe('map'); + expect(component.selectedSectionIndex).toBe(3); + }); + + it('should enable sticky tabs config for shared tabs wrapper', () => { + expect(component.tabsStickyHeader).toBe(true); + expect(component.tabsTopOffset).toBe('0px'); + expect(component.tabsLazyContent).toBe(false); + }); + it('should default privacy to Private if user.privacy is missing', () => { // mockUser has no privacy property component.ngOnChanges(); @@ -141,6 +161,48 @@ describe('UserSettingsComponent', () => { expect(component.userSettingsFormGroup.get('acceptedMarketingPolicy').value).toBe(false); }); + it('should initialize brandText from user data', () => { + (component.user as any).stripeRole = 'basic'; + (component.user as any).brandText = 'My Team'; + component.ngOnChanges(); + + expect(component.userSettingsFormGroup.get('brandText').value).toBe('My Team'); + }); + + it('should initialize brandText as empty string when user has no value', () => { + (component.user as any).stripeRole = 'basic'; + delete (component.user as any).brandText; + component.ngOnChanges(); + + expect(component.userSettingsFormGroup.get('brandText').value).toBe(''); + }); + + it('should allow brandText editing for basic and pro users and disable for free users', () => { + (component.user as any).stripeRole = 'basic'; + component.ngOnChanges(); + expect(component.canEditBrandText).toBe(true); + expect(component.userSettingsFormGroup.get('brandText').disabled).toBe(false); + + (component.user as any).stripeRole = 'pro'; + component.ngOnChanges(); + expect(component.canEditBrandText).toBe(true); + expect(component.userSettingsFormGroup.get('brandText').disabled).toBe(false); + + (component.user as any).stripeRole = 'free'; + component.ngOnChanges(); + expect(component.canEditBrandText).toBe(false); + expect(component.userSettingsFormGroup.get('brandText').disabled).toBe(true); + }); + + it('should allow brandText editing during active grace period', () => { + (component.user as any).stripeRole = 'free'; + (component.user as any).gracePeriodUntil = Date.now() + 60_000; + component.ngOnChanges(); + + expect(component.canEditBrandText).toBe(true); + expect(component.userSettingsFormGroup.get('brandText').disabled).toBe(false); + }); + it('should save acceptedTrackingPolicy when form is submitted', async () => { const userService = TestBed.inject(AppUserService); const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); @@ -185,6 +247,79 @@ describe('UserSettingsComponent', () => { ); }); + it('should save trimmed brandText for paid users', async () => { + const userService = TestBed.inject(AppUserService); + const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); + + (component.user as any).stripeRole = 'basic'; + component.ngOnChanges(); + component.userSettingsFormGroup.get('brandText').setValue(' My Brand '); + + await component.onSubmit(new Event('submit')); + + const payload = updateUserPropertiesSpy.mock.calls[0][1]; + expect(payload.brandText).toBe('My Brand'); + }); + + it('should save null brandText when paid user submits only whitespace', async () => { + const userService = TestBed.inject(AppUserService); + const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); + + (component.user as any).stripeRole = 'pro'; + component.ngOnChanges(); + component.userSettingsFormGroup.get('brandText').setValue(' '); + + await component.onSubmit(new Event('submit')); + + const payload = updateUserPropertiesSpy.mock.calls[0][1]; + expect(payload.brandText).toBeNull(); + }); + + it('should not include brandText in payload for free users', async () => { + const userService = TestBed.inject(AppUserService); + const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); + + (component.user as any).stripeRole = 'free'; + delete (component.user as any).gracePeriodUntil; + component.ngOnChanges(); + component.userSettingsFormGroup.get('brandText').setValue('Should Not Save'); + + await component.onSubmit(new Event('submit')); + + const payload = updateUserPropertiesSpy.mock.calls[0][1]; + expect(payload.brandText).toBeUndefined(); + }); + + it('should save trimmed brandText during active grace period', async () => { + const userService = TestBed.inject(AppUserService); + const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); + + (component.user as any).stripeRole = 'free'; + (component.user as any).gracePeriodUntil = Date.now() + 60_000; + component.ngOnChanges(); + component.userSettingsFormGroup.get('brandText').setValue(' Grace Brand '); + + await component.onSubmit(new Event('submit')); + + const payload = updateUserPropertiesSpy.mock.calls[0][1]; + expect(payload.brandText).toBe('Grace Brand'); + }); + + it('should reject brandText values longer than 30 chars after trim', async () => { + const userService = TestBed.inject(AppUserService); + const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); + + (component.user as any).stripeRole = 'basic'; + component.ngOnChanges(); + component.userSettingsFormGroup.get('brandText').setValue('A'.repeat(31)); + + expect(component.userSettingsFormGroup.get('brandText').hasError('maxTrimmedLength')).toBe(true); + expect(component.userSettingsFormGroup.valid).toBe(false); + + await component.onSubmit(new Event('submit')); + expect(updateUserPropertiesSpy).not.toHaveBeenCalled(); + }); + it('should correctly save chart settings including visible metrics', async () => { const userService = TestBed.inject(AppUserService); const updateUserPropertiesSpy = vi.spyOn(userService, 'updateUserProperties').mockResolvedValue(true as any); diff --git a/src/app/components/user-settings/user-settings.component.ts b/src/app/components/user-settings/user-settings.component.ts index 6445a2896..a41a0e5a0 100644 --- a/src/app/components/user-settings/user-settings.component.ts +++ b/src/app/components/user-settings/user-settings.component.ts @@ -9,7 +9,7 @@ import { AppUserUtilities } from '../../utils/app.user.utilities'; import { MatDialog } from '@angular/material/dialog'; import { DeleteAccountDialogComponent } from '../delete-account-dialog/delete-account-dialog.component'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormControl, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; import { LoggerService } from '../../services/logger.service'; import { Privacy, UserSettingsInterface } from '@sports-alliance/sports-lib'; import { @@ -56,6 +56,18 @@ export class UserSettingsComponent implements OnChanges { public errorDeleting; public errorSaving; public activeSection: 'profile' | 'app' | 'dashboard' | 'map' | 'charts' | 'units' = 'profile'; + public readonly sectionOrder: Array<'profile' | 'app' | 'dashboard' | 'map' | 'charts' | 'units'> = [ + 'profile', + 'app', + 'dashboard', + 'map', + 'charts', + 'units', + ]; + public readonly tabsStickyHeader = true; + public readonly tabsTopOffset = '0px'; + public readonly tabsLazyContent = false; + public readonly brandTextMaxLength = 30; public xAxisTypes = XAxisTypes; @@ -101,6 +113,10 @@ export class UserSettingsComponent implements OnChanges { return AppUserUtilities.isBasicUser(this.user); } + get canEditBrandText(): boolean { + return AppUserUtilities.hasPaidAccessUser(this.user, this.isAdminUser); + } + get userAvatarUrl(): string { if (this.user?.photoURL) { return this.user.photoURL; @@ -123,7 +139,10 @@ export class UserSettingsComponent implements OnChanges { ngOnChanges(): void { if (this.user) { - this.userService.isAdmin().then(isAdmin => this.isAdminUser = isAdmin); + this.userService.isAdmin().then(isAdmin => { + this.isAdminUser = isAdmin; + this.syncBrandTextControlState(); + }); } // Initialize the user settings and get the enabled ones const dataTypesToUse = Object.keys(this.user.settings.chartSettings.dataTypeSettings).filter((dataTypeSettingKey) => { @@ -146,6 +165,13 @@ export class UserSettingsComponent implements OnChanges { ]), acceptedTrackingPolicy: new UntypedFormControl(this.user.acceptedTrackingPolicy, []), acceptedMarketingPolicy: new UntypedFormControl(this.user.acceptedMarketingPolicy || false, []), + brandText: new UntypedFormControl( + { + value: (this.user as any).brandText || '', + disabled: !this.canEditBrandText, + }, + [this.maxTrimmedLength(this.brandTextMaxLength)] + ), chartTheme: new UntypedFormControl(this.user.settings.chartSettings.theme, [ Validators.required, ]), @@ -209,6 +235,8 @@ export class UserSettingsComponent implements OnChanges { Validators.required, ]), }); + + this.syncBrandTextControlState(); } hasError(field?: string) { @@ -226,6 +254,24 @@ export class UserSettingsComponent implements OnChanges { return this.mandatoryDescentExclusions.indexOf(type) >= 0; } + get selectedSectionIndex(): number { + const index = this.sectionOrder.indexOf(this.activeSection); + return index >= 0 ? index : 0; + } + + onSelectedSectionIndexChange(index: number) { + this.activeSection = this.indexToSectionId(index); + } + + sectionIdToIndex(section: 'profile' | 'app' | 'dashboard' | 'map' | 'charts' | 'units'): number { + const index = this.sectionOrder.indexOf(section); + return index >= 0 ? index : 0; + } + + indexToSectionId(index: number): 'profile' | 'app' | 'dashboard' | 'map' | 'charts' | 'units' { + return this.sectionOrder[index] || 'profile'; + } + async onSubmit(event) { event.preventDefault(); if (!this.userSettingsFormGroup.valid) { @@ -270,7 +316,7 @@ export class UserSettingsComponent implements OnChanges { downSamplingLevel: this.userSettingsFormGroup.get('chartDownSamplingLevel').value, }; - await this.userService.updateUserProperties(this.user, { + const propertiesToUpdate: any = { displayName: this.userSettingsFormGroup.get('displayName').value, privacy: this.userSettingsFormGroup.get('privacy').value, description: this.userSettingsFormGroup.get('description').value, @@ -315,7 +361,15 @@ export class UserSettingsComponent implements OnChanges { }, exportToCSVSettings: this.user.settings.exportToCSVSettings } - }); + }; + + if (this.canEditBrandText) { + const rawBrandText = this.userSettingsFormGroup.get('brandText')?.value ?? ''; + const trimmedBrandText = typeof rawBrandText === 'string' ? rawBrandText.trim() : ''; + propertiesToUpdate.brandText = trimmedBrandText.length > 0 ? trimmedBrandText : null; + } + + await this.userService.updateUserProperties(this.user, propertiesToUpdate); this.snackBar.open('User updated', undefined, { duration: 2000, }); @@ -341,6 +395,40 @@ export class UserSettingsComponent implements OnChanges { }); } + private syncBrandTextControlState(): void { + const brandTextControl = this.userSettingsFormGroup?.get('brandText'); + if (!brandTextControl) return; + + if (this.canEditBrandText) { + if (brandTextControl.disabled) { + brandTextControl.enable({ emitEvent: false }); + } + return; + } + + if (brandTextControl.enabled) { + brandTextControl.disable({ emitEvent: false }); + } + } + + private maxTrimmedLength(maxLength: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (typeof control.value !== 'string') { + return null; + } + const trimmedLength = control.value.trim().length; + if (trimmedLength <= maxLength) { + return null; + } + return { + maxTrimmedLength: { + requiredLength: maxLength, + actualLength: trimmedLength, + }, + }; + }; + } + public deleteUser(event: Event) { event.preventDefault(); diff --git a/src/app/components/whats-new/whats-new-dialog.component.ts b/src/app/components/whats-new/whats-new-dialog.component.ts index d5b3ef3eb..2b6da2a30 100644 --- a/src/app/components/whats-new/whats-new-dialog.component.ts +++ b/src/app/components/whats-new/whats-new-dialog.component.ts @@ -10,6 +10,9 @@ import { AppAnalyticsService } from '../../services/app.analytics.service'; import { WhatsNewFeedComponent } from './whats-new-feed.component'; import { Router } from '@angular/router'; import { computed } from '@angular/core'; +import { AppBreakpoints } from '../../constants/breakpoints'; + +const WHATS_NEW_DIALOG_MOBILE_QUERY = AppBreakpoints.XSmall; @Component({ selector: 'app-whats-new-dialog', @@ -120,7 +123,7 @@ import { computed } from '@angular/core'; } } - @media (max-width: 600px) { + @media ${WHATS_NEW_DIALOG_MOBILE_QUERY} { .dialog-content { min-width: unset; width: 100%; diff --git a/src/app/constants/breakpoints.spec.ts b/src/app/constants/breakpoints.spec.ts index 68f44544c..24847a6d2 100644 --- a/src/app/constants/breakpoints.spec.ts +++ b/src/app/constants/breakpoints.spec.ts @@ -21,12 +21,51 @@ describe('AppBreakpoints', () => { expect(AppBreakpoints.XLarge).toBe('(min-width: 1920px)'); }); + it('should define Max480 breakpoint', () => { + expect(AppBreakpoints.Max480).toBe('(max-width: 480px)'); + }); + + it('should define Max640 breakpoint', () => { + expect(AppBreakpoints.Max640).toBe('(max-width: 640px)'); + }); + + it('should define Max768 breakpoint', () => { + expect(AppBreakpoints.Max768).toBe('(max-width: 768px)'); + }); + + it('should define Max900 breakpoint', () => { + expect(AppBreakpoints.Max900).toBe('(max-width: 900px)'); + }); + + it('should define Max1024 breakpoint', () => { + expect(AppBreakpoints.Max1024).toBe('(max-width: 1024px)'); + }); + + it('should define Min768 breakpoint', () => { + expect(AppBreakpoints.Min768).toBe('(min-width: 768px)'); + }); + it('should define HandsetOrTabletPortrait breakpoint', () => { expect(AppBreakpoints.HandsetOrTabletPortrait).toBe('(max-width: 959.98px)'); }); it('should have all expected breakpoint keys', () => { - const expectedKeys = ['XSmall', 'Small', 'Medium', 'Large', 'XLarge', 'Handset', 'Tablet', 'HandsetOrTabletPortrait']; + const expectedKeys = [ + 'XSmall', + 'Small', + 'Medium', + 'Large', + 'XLarge', + 'Max480', + 'Max640', + 'Max768', + 'Max900', + 'Max1024', + 'Min768', + 'Handset', + 'Tablet', + 'HandsetOrTabletPortrait', + ]; expect(Object.keys(AppBreakpoints)).toEqual(expect.arrayContaining(expectedKeys)); }); }); diff --git a/src/app/constants/breakpoints.ts b/src/app/constants/breakpoints.ts index b2dcf4baf..3efd18068 100644 --- a/src/app/constants/breakpoints.ts +++ b/src/app/constants/breakpoints.ts @@ -21,6 +21,24 @@ export const AppBreakpoints = { /** Extra large desktop (1920px+) */ XLarge: '(min-width: 1920px)', + /** Legacy parity breakpoint: 480px and below */ + Max480: '(max-width: 480px)', + + /** Legacy parity breakpoint: 640px and below */ + Max640: '(max-width: 640px)', + + /** Legacy parity breakpoint: 768px and below */ + Max768: '(max-width: 768px)', + + /** Legacy parity breakpoint: 900px and below */ + Max900: '(max-width: 900px)', + + /** Legacy parity breakpoint: 1024px and below */ + Max1024: '(max-width: 1024px)', + + /** Legacy parity breakpoint: 768px and above */ + Min768: '(min-width: 768px)', + /** Handset (portrait or landscape) */ Handset: '(max-width: 599.98px), (min-width: 600px) and (max-width: 959.98px) and (orientation: portrait)', diff --git a/src/app/constants/event-summary-metric-groups.ts b/src/app/constants/event-summary-metric-groups.ts new file mode 100644 index 000000000..fe890b9e2 --- /dev/null +++ b/src/app/constants/event-summary-metric-groups.ts @@ -0,0 +1,480 @@ +import { + DataAbsolutePressure, + DataAbsolutePressureAvg, + DataAbsolutePressureMax, + DataAbsolutePressureMin, + DataAccumulatedPower, + DataAerobicTrainingEffect, + DataAge, + DataAirPower, + DataAirPowerAvg, + DataAirPowerMax, + DataAirPowerMin, + DataAltitudeAvg, + DataAltitudeMax, + DataAltitudeMin, + DataAnaerobicTrainingEffect, + DataAscent, + DataAscentTime, + DataAvgFlow, + DataAvgGrit, + DataAvgRespirationRate, + DataAvgVAM, + DataBatteryCharge, + DataBatteryConsumption, + DataBatteryCurrent, + DataBatteryVoltage, + DataCadenceAvg, + DataCadenceMax, + DataCadenceMin, + DataCriticalPower, + DataDescent, + DataDescentTime, + DataDistance, + DataDuration, + DataEffortPace, + DataEHPE, + DataEHPEAvg, + DataEHPEMax, + DataEHPEMin, + DataEnergy, + DataEPOC, + DataEVPE, + DataEVPEAvg, + DataEVPEMax, + DataEVPEMin, + DataFeeling, + DataFitnessAge, + DataFormPower, + DataFTP, + DataGender, + DataGNSSDistance, + DataGrade, + DataGradeAdjustedPaceAvg, + DataGradeAdjustedPaceMax, + DataGradeAdjustedPaceMin, + DataGradeAdjustedSpeedAvg, + DataGradeAdjustedSpeedMax, + DataGradeAdjustedSpeedMin, + DataGradeAvg, + DataGradeMax, + DataGradeMin, + DataGrit, + DataGroundContactTimeAvg, + DataGroundContactTimeBalanceLeft, + DataGroundContactTimeBalanceRight, + DataGroundContactTimeMax, + DataGroundContactTimeMin, + DataHeartRateAvg, + DataHeartRateMax, + DataHeartRateMin, + DataHeight, + DataJumpCount, + DataJumpDistance, + DataJumpDistanceAvg, + DataJumpDistanceMax, + DataJumpDistanceMin, + DataJumpHangTimeAvg, + DataJumpHangTimeMax, + DataJumpHangTimeMin, + DataJumpHeightAvg, + DataJumpHeightMax, + DataJumpHeightMin, + DataJumpRotationsAvg, + DataJumpRotationsMax, + DataJumpRotationsMin, + DataJumpScoreAvg, + DataJumpScoreMax, + DataJumpScoreMin, + DataJumpSpeedAvg, + DataJumpSpeedMax, + DataJumpSpeedMin, + DataLegStiffness, + DataLegStiffnessAvg, + DataLegStiffnessMax, + DataLegStiffnessMin, + DataMaxRespirationRate, + DataMinRespirationRate, + DataMovingTime, + DataNumberOfSatellites, + DataNumberOfSatellitesAvg, + DataNumberOfSatellitesMax, + DataNumberOfSatellitesMin, + DataPaceAvg, + DataPeakEPOC, + DataPower, + DataPowerAvg, + DataPowerIntensityFactor, + DataPowerLeft, + DataPowerMax, + DataPowerMin, + DataPowerNormalized, + DataPowerPedalSmoothnessLeft, + DataPowerPedalSmoothnessRight, + DataPowerPodUsed, + DataPowerRight, + DataPowerTorqueEffectivenessLeft, + DataPowerTorqueEffectivenessRight, + DataPowerTrainingStressScore, + DataPowerWattsPerKg, + DataPowerWork, + DataRecoveryTime, + DataRPE, + DataSatellite5BestSNR, + DataSatellite5BestSNRAvg, + DataSatellite5BestSNRMax, + DataSatellite5BestSNRMin, + DataSpeedAvg, + DataStanceTime, + DataStanceTimeBalanceLeft, + DataStanceTimeBalanceRight, + DataStrydDistance, + DataSwimPaceAvg, + DataTargetPowerZone, + DataTemperatureAvg, + DataTemperatureMax, + DataTemperatureMin, + DataTotalGrit, + DataVerticalOscillation, + DataVerticalRatio, + DataVerticalRatioAvg, + DataVerticalRatioMax, + DataVerticalRatioMin, + DataVerticalSpeedAvg, + DataVerticalSpeedMax, + DataVO2Max, + DataWeight, + DataWPrime, +} from '@sports-alliance/sports-lib'; + +export type EventSummaryMetricGroupId = + | 'overall' + | 'performance' + | 'altitude' + | 'environment' + | 'device' + | 'physiological' + | 'other'; + +export interface EventSummaryMetricGroupConfig { + id: EventSummaryMetricGroupId; + label: string; + metricTypes: string[]; + singleValueTypes?: string[]; +} + +const POWER_LIB_EXTRA_TYPE_STRINGS: string[] = [ + DataPowerNormalized.type, + DataPowerIntensityFactor.type, + DataPowerTrainingStressScore.type, + DataFTP.type, + DataPowerWork.type, + DataPowerWattsPerKg.type, + DataCriticalPower.type, + DataWPrime.type, + DataFormPower.type, + DataPowerPodUsed.type, + DataAirPowerAvg.type, + DataAirPowerMax.type, + DataAirPowerMin.type, + DataPowerPedalSmoothnessLeft.type, + DataPowerPedalSmoothnessRight.type, + DataPowerTorqueEffectivenessLeft.type, + DataPowerTorqueEffectivenessRight.type, + DataTargetPowerZone.type, +]; + +const ALTITUDE_LIB_EXTRA_TYPE_STRINGS: string[] = [ + DataAscentTime.type, + DataDescentTime.type, +]; + +const PHYSIOLOGICAL_EXTRA_TYPE_STRINGS: string[] = [ + DataAvgRespirationRate.type, + DataMinRespirationRate.type, + DataMaxRespirationRate.type, + DataWeight.type, + DataHeight.type, + DataGender.type, + DataFitnessAge.type, + DataAge.type, +]; + +const PERFORMANCE_EXTRA_TYPE_STRINGS: string[] = [ + DataEffortPace.type, + DataAvgVAM.type, + DataEPOC.type, + DataAvgFlow.type, + DataGrit.type, + DataAvgGrit.type, + DataTotalGrit.type, +]; + +const PERFORMANCE_JUMP_TYPE_STRINGS: string[] = [ + DataJumpCount.type, + DataJumpDistance.type, + DataJumpDistanceAvg.type, + DataJumpDistanceMin.type, + DataJumpDistanceMax.type, + DataJumpHangTimeAvg.type, + DataJumpHangTimeMin.type, + DataJumpHangTimeMax.type, + DataJumpHeightAvg.type, + DataJumpHeightMin.type, + DataJumpHeightMax.type, + DataJumpSpeedAvg.type, + DataJumpSpeedMin.type, + DataJumpSpeedMax.type, + DataJumpRotationsAvg.type, + DataJumpRotationsMin.type, + DataJumpRotationsMax.type, + DataJumpScoreAvg.type, + DataJumpScoreMin.type, + DataJumpScoreMax.type, +]; + +export const EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES: string[] = [ + DataGradeAdjustedSpeedAvg.type, + DataGradeAdjustedSpeedMin.type, + DataGradeAdjustedSpeedMax.type, +]; + +export const EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES: string[] = [ + DataGradeAdjustedPaceAvg.type, + DataGradeAdjustedPaceMin.type, + DataGradeAdjustedPaceMax.type, +]; + +const PERFORMANCE_RUN_DYNAMICS_TYPE_STRINGS: string[] = [ + DataGroundContactTimeAvg.type, + DataGroundContactTimeMin.type, + DataGroundContactTimeMax.type, + DataStanceTime.type, + DataStanceTimeBalanceLeft.type, + DataStanceTimeBalanceRight.type, + DataGroundContactTimeBalanceLeft.type, + DataGroundContactTimeBalanceRight.type, + DataVerticalOscillation.type, + DataVerticalRatio.type, + DataVerticalRatioAvg.type, + DataVerticalRatioMin.type, + DataVerticalRatioMax.type, + DataLegStiffness.type, + DataLegStiffnessAvg.type, + DataLegStiffnessMin.type, + DataLegStiffnessMax.type, +]; + +const DEVICE_EXTRA_TYPE_STRINGS: string[] = [ + DataBatteryCharge.type, + DataBatteryConsumption.type, + DataBatteryCurrent.type, + DataBatteryVoltage.type, +]; + +const DEVICE_SIGNAL_EXTRA_TYPE_STRINGS: string[] = [ + DataEVPE.type, + DataEVPEAvg.type, + DataEVPEMin.type, + DataEVPEMax.type, + DataEHPE.type, + DataEHPEAvg.type, + DataEHPEMin.type, + DataEHPEMax.type, + DataSatellite5BestSNR.type, + DataSatellite5BestSNRAvg.type, + DataSatellite5BestSNRMin.type, + DataSatellite5BestSNRMax.type, + DataNumberOfSatellites.type, + DataNumberOfSatellitesAvg.type, + DataNumberOfSatellitesMin.type, + DataNumberOfSatellitesMax.type, +]; + +const ENVIRONMENT_ABSOLUTE_PRESSURE_TYPE_STRINGS: string[] = [ + DataAbsolutePressure.type, + DataAbsolutePressureAvg.type, + DataAbsolutePressureMin.type, + DataAbsolutePressureMax.type, +]; + +const ENVIRONMENT_GRADE_TYPE_STRINGS: string[] = [ + DataGrade.type, + DataGradeAvg.type, + DataGradeMin.type, + DataGradeMax.type, +]; + +const ENVIRONMENT_DISTANCE_TYPE_STRINGS: string[] = [ + DataStrydDistance.type, + DataGNSSDistance.type, +]; + +export const EVENT_SUMMARY_DEFAULT_GROUP_ID: EventSummaryMetricGroupId = 'overall'; + +export const EVENT_SUMMARY_METRIC_GROUPS: EventSummaryMetricGroupConfig[] = [ + { + id: 'overall', + label: 'Overall', + metricTypes: [ + DataDuration.type, + DataMovingTime.type, + DataDistance.type, + DataSpeedAvg.type, + DataPaceAvg.type, + DataSwimPaceAvg.type, + DataHeartRateAvg.type, + DataPowerAvg.type, + DataPowerNormalized.type, + DataRecoveryTime.type, + DataVO2Max.type, + DataAscent.type, + DataDescent.type, + DataCadenceAvg.type, + DataFTP.type, + ], + singleValueTypes: [ + DataHeartRateAvg.type, + DataPowerAvg.type, + DataSpeedAvg.type, + DataPaceAvg.type, + DataSwimPaceAvg.type, + DataCadenceAvg.type, + ], + }, + { + id: 'performance', + label: 'Performance', + metricTypes: [ + DataSpeedAvg.type, + DataPaceAvg.type, + DataSwimPaceAvg.type, + ...EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES, + ...EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES, + DataVerticalSpeedMax.type, + DataCadenceAvg.type, + DataCadenceMax.type, + DataCadenceMin.type, + DataPower.type, + DataPowerAvg.type, + DataPowerMax.type, + DataPowerMin.type, + DataPowerLeft.type, + DataPowerRight.type, + DataAccumulatedPower.type, + DataAirPower.type, + DataHeartRateAvg.type, + DataHeartRateMax.type, + DataHeartRateMin.type, + ...PERFORMANCE_EXTRA_TYPE_STRINGS, + ...PERFORMANCE_JUMP_TYPE_STRINGS, + ...PERFORMANCE_RUN_DYNAMICS_TYPE_STRINGS, + ...POWER_LIB_EXTRA_TYPE_STRINGS, + ], + singleValueTypes: [ + DataAvgFlow.type, + ], + }, + { + id: 'altitude', + label: 'Altitude', + metricTypes: [], + }, + { + id: 'environment', + label: 'Environment', + metricTypes: [ + DataAscent.type, + DataDescent.type, + ...ALTITUDE_LIB_EXTRA_TYPE_STRINGS, + DataAltitudeMax.type, + DataAltitudeMin.type, + DataAltitudeAvg.type, + DataTemperatureAvg.type, + DataTemperatureMax.type, + DataTemperatureMin.type, + ...ENVIRONMENT_ABSOLUTE_PRESSURE_TYPE_STRINGS, + ...ENVIRONMENT_GRADE_TYPE_STRINGS, + ...ENVIRONMENT_DISTANCE_TYPE_STRINGS, + ], + }, + { + id: 'device', + label: 'Device', + metricTypes: [ + ...DEVICE_EXTRA_TYPE_STRINGS, + ...DEVICE_SIGNAL_EXTRA_TYPE_STRINGS, + ], + }, + { + id: 'physiological', + label: 'Physiological', + metricTypes: [ + DataEnergy.type, + DataVO2Max.type, + DataPeakEPOC.type, + DataAerobicTrainingEffect.type, + DataAnaerobicTrainingEffect.type, + DataRecoveryTime.type, + DataFeeling.type, + DataRPE.type, + ...PHYSIOLOGICAL_EXTRA_TYPE_STRINGS, + ], + }, + { + id: 'other', + label: 'Other', + metricTypes: [], + }, +]; + +export const EVENT_SUMMARY_DEFAULT_STAT_TYPES: string[] = [ + DataDuration.type, + DataMovingTime.type, + DataDistance.type, + DataSpeedAvg.type, + ...EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES, + ...EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES, + DataVerticalSpeedMax.type, + DataEnergy.type, + DataPower.type, + DataPowerAvg.type, + DataPowerMax.type, + DataPowerMin.type, + DataPowerLeft.type, + DataPowerRight.type, + DataAccumulatedPower.type, + DataAirPower.type, + ...PERFORMANCE_EXTRA_TYPE_STRINGS, + ...PERFORMANCE_JUMP_TYPE_STRINGS, + ...PERFORMANCE_RUN_DYNAMICS_TYPE_STRINGS, + ...POWER_LIB_EXTRA_TYPE_STRINGS, + DataAscent.type, + DataDescent.type, + ...ALTITUDE_LIB_EXTRA_TYPE_STRINGS, + DataAltitudeMax.type, + DataAltitudeMin.type, + DataAltitudeAvg.type, + DataCadenceAvg.type, + DataCadenceMax.type, + DataCadenceMin.type, + DataTemperatureAvg.type, + DataTemperatureMax.type, + DataTemperatureMin.type, + ...ENVIRONMENT_ABSOLUTE_PRESSURE_TYPE_STRINGS, + ...ENVIRONMENT_GRADE_TYPE_STRINGS, + ...ENVIRONMENT_DISTANCE_TYPE_STRINGS, + ...DEVICE_EXTRA_TYPE_STRINGS, + ...DEVICE_SIGNAL_EXTRA_TYPE_STRINGS, + DataHeartRateAvg.type, + DataHeartRateMax.type, + DataHeartRateMin.type, + DataRecoveryTime.type, + DataPeakEPOC.type, + DataAerobicTrainingEffect.type, + DataAnaerobicTrainingEffect.type, + DataVO2Max.type, + DataFeeling.type, + DataRPE.type, + ...PHYSIOLOGICAL_EXTRA_TYPE_STRINGS, +]; diff --git a/src/app/helpers/date-range-helper.ts b/src/app/helpers/date-range-helper.ts index 93168e0f2..adad0818f 100644 --- a/src/app/helpers/date-range-helper.ts +++ b/src/app/helpers/date-range-helper.ts @@ -14,16 +14,16 @@ export function getDatesForDateRange(dateRange: DateRanges, startOfTheWeek: Days // First day of this week const fistDayOfTheWeekDate = new Date(new Date().setDate(firstDayOfTheWeek - daysBack)); - fistDayOfTheWeekDate.setHours(0, 0, 0); + fistDayOfTheWeekDate.setHours(0, 0, 0, 0); // Last day if this week const lastDayOfTheWeekDate = new Date(new Date().setDate(lastDayOfTheWeek - daysBack)); - lastDayOfTheWeekDate.setHours(23, 59, 59); + lastDayOfTheWeekDate.setHours(23, 59, 59, 999); // Take the first day of this week and go back 7 days const firstDayOfLastWeekDate = new Date(new Date(fistDayOfTheWeekDate).setDate(fistDayOfTheWeekDate.getDate() - 7)); // Needs to base on fistDayOfTheWeekDate for new Date() - firstDayOfLastWeekDate.setHours(0, 0, 0); + firstDayOfLastWeekDate.setHours(0, 0, 0, 0); // Take the first day of this week and go back 1second const lastDayOfLastWeekDate = new Date(new Date(fistDayOfTheWeekDate.getTime()).setHours(0, 0, -1)); @@ -62,7 +62,7 @@ export function getDatesForDateRange(dateRange: DateRanges, startOfTheWeek: Days case DateRanges.lastMonth: { return { startDate: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1), - endDate: new Date(new Date(new Date().getFullYear(), new Date().getMonth(), 0).setHours(23, 59, 59)) + endDate: new Date(new Date(new Date().getFullYear(), new Date().getMonth(), 0).setHours(23, 59, 59, 999)) } } case DateRanges.thisYear: { @@ -74,7 +74,7 @@ export function getDatesForDateRange(dateRange: DateRanges, startOfTheWeek: Days case DateRanges.lastYear: { return { startDate: new Date(new Date().getFullYear() - 1, 0, 1), - endDate: new Date(new Date(new Date().getFullYear(), 0, 0).setHours(23, 59, 59)) + endDate: new Date(new Date(new Date().getFullYear(), 0, 0).setHours(23, 59, 59, 999)) } } default: { diff --git a/src/app/helpers/header-stats-composite.helper.spec.ts b/src/app/helpers/header-stats-composite.helper.spec.ts new file mode 100644 index 000000000..bf67629d4 --- /dev/null +++ b/src/app/helpers/header-stats-composite.helper.spec.ts @@ -0,0 +1,182 @@ +import { + DataAltitudeAvg, + DataCadenceAvg, + DataHeartRateAvg, + DataJumpHeightAvg, + DataJumpHeightMax, + DataJumpHeightMin, + DataPaceAvgMinutesPerMile, + DataPowerAvg, + DataPowerMax, + DataPowerMin, + DataSpeedAvg, + DataSpeedAvgKilometersPerHour, + DataTemperatureAvg +} from '@sports-alliance/sports-lib'; +import { describe, expect, it } from 'vitest'; +import { buildHeaderStatCards, expandStatsTypesForCompositeDiff, resolveMetricFamilyTypes } from './header-stats-composite.helper'; + +describe('header-stats-composite.helper', () => { + it('should resolve family types for title-case and lower-case average labels', () => { + const titleCase = resolveMetricFamilyTypes('Average Speed in kilometers per hour'); + const lowerCase = resolveMetricFamilyTypes('average speed in kilometers per hour'); + + expect(titleCase).toBeTruthy(); + expect(lowerCase).toBeTruthy(); + expect(titleCase?.familyType).toBe('Speed in kilometers per hour'); + expect(lowerCase?.familyType).toBe('Speed in kilometers per hour'); + expect(titleCase?.avgType).toBe(DataSpeedAvgKilometersPerHour.type); + expect(lowerCase?.avgType).toBe(DataSpeedAvgKilometersPerHour.type); + }); + + it('should resolve avg/min/max triplets for supported metric families', () => { + const families = [ + resolveMetricFamilyTypes(DataPowerAvg.type), + resolveMetricFamilyTypes(DataHeartRateAvg.type), + resolveMetricFamilyTypes(DataCadenceAvg.type), + resolveMetricFamilyTypes(DataTemperatureAvg.type), + resolveMetricFamilyTypes(DataAltitudeAvg.type), + resolveMetricFamilyTypes(DataSpeedAvgKilometersPerHour.type), + resolveMetricFamilyTypes(DataPaceAvgMinutesPerMile.type), + resolveMetricFamilyTypes('Average Ground Contact Time'), + resolveMetricFamilyTypes(DataJumpHeightAvg.type), + ]; + + families.forEach((family) => { + expect(family).toBeTruthy(); + expect(family?.avgType).toBeTruthy(); + expect(family?.minType).toBeTruthy(); + expect(family?.maxType).toBeTruthy(); + }); + }); + + it('should expand diff source types with family triplets and dedupe', () => { + const expanded = expandStatsTypesForCompositeDiff([ + DataPowerAvg.type, + DataPowerMin.type, + ]); + + expect(expanded).toContain(DataPowerAvg.type); + expect(expanded).toContain(DataPowerMin.type); + expect(expanded).toContain(DataPowerMax.type); + expect(expanded.filter((type) => type === DataPowerAvg.type).length).toBe(1); + expect(expanded.filter((type) => type === DataPowerMin.type).length).toBe(1); + expect(expanded.filter((type) => type === DataPowerMax.type).length).toBe(1); + }); + + it('should expand diff source types for ground contact time family', () => { + const expanded = expandStatsTypesForCompositeDiff(['Average Ground Contact Time']); + + expect(expanded).toContain('Average Ground Contact Time'); + expect(expanded).toContain('Minimum Ground Contact Time'); + expect(expanded).toContain('Maximum Ground Contact Time'); + }); + + it('should resolve jump height family with avg/min/max triplet', () => { + const family = resolveMetricFamilyTypes(DataJumpHeightAvg.type); + + expect(family).toBeTruthy(); + expect(family?.familyType).toBe('Jump Height'); + expect(family?.avgType).toBe(DataJumpHeightAvg.type); + expect(family?.minType).toBe(DataJumpHeightMin.type); + expect(family?.maxType).toBe(DataJumpHeightMax.type); + }); + + it('should expand diff source types for jump height family', () => { + const expanded = expandStatsTypesForCompositeDiff([DataJumpHeightAvg.type]); + + expect(expanded).toContain(DataJumpHeightAvg.type); + expect(expanded).toContain(DataJumpHeightMin.type); + expect(expanded).toContain(DataJumpHeightMax.type); + }); + + it('should force single card by family when avg type is configured as single-value', () => { + const speedFamily = resolveMetricFamilyTypes(DataSpeedAvgKilometersPerHour.type)!; + + const createStat = (type: string, label: string, value: string, unit = '') => ({ + getType: () => type, + getDisplayType: () => label, + getDisplayValue: () => value, + getDisplayUnit: () => unit, + } as any); + + const avgStat = createStat(speedFamily.avgType!, 'Average speed in kilometers per hour', '30', 'km/h'); + const minStat = createStat(speedFamily.minType!, 'Minimum speed in kilometers per hour', '10', 'km/h'); + const maxStat = createStat(speedFamily.maxType!, 'Maximum speed in kilometers per hour', '55', 'km/h'); + + const cards = buildHeaderStatCards( + [avgStat], + new Map([ + [avgStat.getType(), avgStat], + [minStat.getType(), minStat], + [maxStat.getType(), maxStat], + ]), + [DataSpeedAvg.type] + ); + + expect(cards.length).toBe(1); + expect(cards[0].isComposite).toBe(false); + expect(cards[0].label).toBe('Average Speed'); + expect(cards[0].valueItems.map((item) => item.type)).toEqual([avgStat.getType()]); + }); + + it('should normalize composite family and value labels for unit-derived stats', () => { + const speedFamily = resolveMetricFamilyTypes(DataSpeedAvgKilometersPerHour.type)!; + + const createStat = (type: string, label: string, value: string, unit = '') => ({ + getType: () => type, + getDisplayType: () => label, + getDisplayValue: () => value, + getDisplayUnit: () => unit, + } as any); + + const avgStat = createStat(speedFamily.avgType!, 'Average speed in kilometers per hour', '30', 'km/h'); + const minStat = createStat(speedFamily.minType!, 'Minimum speed in kilometers per hour', '10', 'km/h'); + const maxStat = createStat(speedFamily.maxType!, 'Maximum speed in kilometers per hour', '55', 'km/h'); + + const cards = buildHeaderStatCards( + [avgStat], + new Map([ + [avgStat.getType(), avgStat], + [minStat.getType(), minStat], + [maxStat.getType(), maxStat], + ]) + ); + + expect(cards.length).toBe(1); + expect(cards[0].isComposite).toBe(true); + expect(cards[0].label).toBe('Speed'); + expect(cards[0].valueItems.map((item) => item.displayType)).toEqual([ + 'Average Speed', + 'Minimum Speed', + 'Maximum Speed', + ]); + }); + + it('should strip trailing units from composite value display cells', () => { + const createStat = (type: string, label: string, value: string, unit = '') => ({ + getType: () => type, + getDisplayType: () => label, + getDisplayValue: () => value, + getDisplayUnit: () => unit, + } as any); + + const avgStat = createStat(DataPowerAvg.type, 'Average Power', '250 W', 'W'); + const minStat = createStat(DataPowerMin.type, 'Minimum Power', '120 W', 'W'); + const maxStat = createStat(DataPowerMax.type, 'Maximum Power', '680 W', 'W'); + + const cards = buildHeaderStatCards( + [avgStat], + new Map([ + [avgStat.getType(), avgStat], + [minStat.getType(), minStat], + [maxStat.getType(), maxStat], + ]) + ); + + expect(cards.length).toBe(1); + expect(cards[0].isComposite).toBe(true); + expect(cards[0].valueItems.map((item) => item.displayValue)).toEqual(['250', '120', '680']); + expect(cards[0].valueItems.map((item) => item.displayUnit)).toEqual(['W', 'W', 'W']); + }); +}); diff --git a/src/app/helpers/header-stats-composite.helper.ts b/src/app/helpers/header-stats-composite.helper.ts new file mode 100644 index 000000000..15776cd4c --- /dev/null +++ b/src/app/helpers/header-stats-composite.helper.ts @@ -0,0 +1,253 @@ +import { DataInterface, DynamicDataLoader } from '@sports-alliance/sports-lib'; +import { normalizeUnitDerivedStatLabel, normalizeUnitDerivedTypeLabel } from './stat-label.helper'; + +export type HeaderStatValueKind = 'avg' | 'min' | 'max' | 'single'; + +export interface HeaderStatValueItem { + key: 'AVG' | 'MIN' | 'MAX' | 'VALUE'; + kind: HeaderStatValueKind; + type: string; + displayType: string; + displayValue: string; + displayUnit: string; +} + +export interface HeaderStatCard { + id: string; + label: string; + iconType: string; + isComposite: boolean; + valueItems: HeaderStatValueItem[]; +} + +interface MetricFamilyTypes { + familyType: string; + avgType?: string; + minType?: string; + maxType?: string; +} + +const STAT_KIND_PREFIX_REGEX = /^(average|minimum|maximum)\s+/i; +const DATA_TYPE_KEYS = [ + ...Object.keys(DynamicDataLoader.dataTypeAvgDataType || {}), + ...Object.keys(DynamicDataLoader.dataTypeMinDataType || {}), + ...Object.keys(DynamicDataLoader.dataTypeMaxDataType || {}), +]; +const DATA_TYPE_KEY_TO_CANONICAL = DATA_TYPE_KEYS.reduce((acc, key) => { + const normalized = key.trim().toLowerCase(); + if (normalized.length && !acc.has(normalized)) { + acc.set(normalized, key); + } + return acc; +}, new Map()); + +const toDisplayText = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + return String(value); +}; + +const stripTrailingDisplayUnit = (displayValue: string, displayUnit: string): string => { + const value = displayValue.trim(); + const unit = displayUnit.trim(); + if (!unit || !value) { + return value; + } + + const lowerValue = value.toLowerCase(); + const lowerUnit = unit.toLowerCase(); + const spacedUnitSuffix = ` ${lowerUnit}`; + + if (lowerValue.endsWith(spacedUnitSuffix)) { + return value.slice(0, value.length - spacedUnitSuffix.length).trim(); + } + if (lowerValue.endsWith(lowerUnit)) { + return value.slice(0, value.length - lowerUnit.length).trim(); + } + + return value; +}; + +const normalizeFamilyKey = (type: string): string => { + return type.replace(STAT_KIND_PREFIX_REGEX, '').trim().toLowerCase(); +}; + +export const resolveMetricFamilyTypes = (statType: string): MetricFamilyTypes | null => { + if (!statType) { + return null; + } + const normalizedKey = normalizeFamilyKey(statType); + const canonicalFamilyType = DATA_TYPE_KEY_TO_CANONICAL.get(normalizedKey); + if (!canonicalFamilyType) { + return null; + } + + const avgType = DynamicDataLoader.dataTypeAvgDataType[canonicalFamilyType]; + const minType = DynamicDataLoader.dataTypeMinDataType[canonicalFamilyType]; + const maxType = DynamicDataLoader.dataTypeMaxDataType[canonicalFamilyType]; + if (!avgType && !minType && !maxType) { + return null; + } + + return { + familyType: canonicalFamilyType, + avgType, + minType, + maxType, + }; +}; + +const createValueItem = ( + kind: HeaderStatValueKind, + key: HeaderStatValueItem['key'], + stat: DataInterface +): HeaderStatValueItem => { + return { + kind, + key, + type: stat.getType(), + displayType: normalizeUnitDerivedStatLabel(stat), + displayValue: toDisplayText(stat.getDisplayValue()), + displayUnit: toDisplayText(stat.getDisplayUnit()), + }; +}; + +const createSingleCard = (stat: DataInterface): HeaderStatCard => { + return { + id: `single:${stat.getType()}`, + label: normalizeUnitDerivedStatLabel(stat), + iconType: stat.getType(), + isComposite: false, + valueItems: [createValueItem('single', 'VALUE', stat)], + }; +}; + +export const buildHeaderStatCards = ( + displayedStats: DataInterface[], + expandedStatsMap: Map, + singleValueTypes: string[] = [] +): HeaderStatCard[] => { + if (!displayedStats.length) { + return []; + } + + const cards: HeaderStatCard[] = []; + const processedKeys = new Set(); + const singleValueTypeSet = new Set(singleValueTypes); + const singleValueFamilySet = new Set(); + const familyMatches = (familyType: string): boolean => { + const normalizedFamilyType = familyType.toLowerCase(); + for (const configuredFamily of singleValueFamilySet.values()) { + const normalizedConfiguredFamily = configuredFamily.toLowerCase(); + if (normalizedFamilyType === normalizedConfiguredFamily) { + return true; + } + if (normalizedFamilyType.startsWith(`${normalizedConfiguredFamily} in `)) { + return true; + } + } + return false; + }; + + singleValueTypes.forEach((type) => { + const family = resolveMetricFamilyTypes(type); + if (family?.familyType) { + singleValueFamilySet.add(family.familyType); + } + }); + + displayedStats.forEach((stat) => { + const familyTypes = resolveMetricFamilyTypes(stat.getType()); + const shouldForceSingle = singleValueTypeSet.has(stat.getType()) + || (!!familyTypes?.familyType && familyMatches(familyTypes.familyType)); + + if (shouldForceSingle) { + const singleKey = `single:${stat.getType()}`; + if (processedKeys.has(singleKey)) { + return; + } + processedKeys.add(singleKey); + cards.push(createSingleCard(stat)); + return; + } + + if (!familyTypes) { + const singleKey = `single:${stat.getType()}`; + if (processedKeys.has(singleKey)) { + return; + } + processedKeys.add(singleKey); + cards.push(createSingleCard(stat)); + return; + } + + const familyKey = `family:${familyTypes.familyType}`; + if (processedKeys.has(familyKey)) { + return; + } + processedKeys.add(familyKey); + + const valueItems: HeaderStatValueItem[] = []; + const familyEntries: Array<{ kind: HeaderStatValueKind; key: HeaderStatValueItem['key']; type?: string }> = [ + { kind: 'avg', key: 'AVG', type: familyTypes.avgType }, + { kind: 'min', key: 'MIN', type: familyTypes.minType }, + { kind: 'max', key: 'MAX', type: familyTypes.maxType }, + ]; + + familyEntries.forEach((entry) => { + if (!entry.type) { + return; + } + const familyStat = expandedStatsMap.get(entry.type); + if (!familyStat) { + return; + } + const valueItem = createValueItem(entry.kind, entry.key, familyStat); + valueItems.push({ + ...valueItem, + displayValue: stripTrailingDisplayUnit(valueItem.displayValue, valueItem.displayUnit), + }); + }); + + if (!valueItems.length) { + cards.push(createSingleCard(stat)); + return; + } + + cards.push({ + id: familyKey, + label: normalizeUnitDerivedTypeLabel(familyTypes.familyType, familyTypes.familyType), + iconType: valueItems[0].type, + isComposite: true, + valueItems, + }); + }); + + return cards; +}; + +export const expandStatsTypesForCompositeDiff = (statsTypes: string[]): string[] => { + const expanded = new Set(); + + statsTypes.forEach((statType) => { + expanded.add(statType); + + const familyTypes = resolveMetricFamilyTypes(statType); + if (!familyTypes) { + return; + } + + if (familyTypes.avgType) { + expanded.add(familyTypes.avgType); + } + if (familyTypes.minType) { + expanded.add(familyTypes.minType); + } + if (familyTypes.maxType) { + expanded.add(familyTypes.maxType); + } + }); + + return [...expanded.values()]; +}; diff --git a/src/app/helpers/intensity-zones-chart-data-helper.spec.ts b/src/app/helpers/intensity-zones-chart-data-helper.spec.ts index 86e54c644..df448344f 100644 --- a/src/app/helpers/intensity-zones-chart-data-helper.spec.ts +++ b/src/app/helpers/intensity-zones-chart-data-helper.spec.ts @@ -1,6 +1,11 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { ActivityUtilities } from '@sports-alliance/sports-lib'; -import { convertIntensityZonesStatsToChartData, getActiveDataTypes } from './intensity-zones-chart-data-helper'; +import { + convertIntensityZonesStatsToChartData, + convertIntensityZonesStatsToEchartsData, + getActiveDataTypes, + shouldRenderIntensityZonesChart +} from './intensity-zones-chart-data-helper'; // Mock the sports-lib dependencies vi.mock('@sports-alliance/sports-lib', async (importOriginal) => { @@ -13,6 +18,10 @@ vi.mock('@sports-alliance/sports-lib', async (importOriginal) => { { type: 'Heart Rate', stats: ['Zone1HR', 'Zone2HR', 'Zone3HR', 'Zone4HR', 'Zone5HR', 'Zone6HR', 'Zone7HR'] + }, + { + type: 'Power', + stats: ['Zone1Power', 'Zone2Power', 'Zone3Power', 'Zone4Power', 'Zone5Power', 'Zone6Power', 'Zone7Power'] } ] }, @@ -35,6 +44,13 @@ describe('convertIntensityZonesStatsToChartData', () => { { getType: () => 'Zone5HR', getValue: () => 5000 }, { getType: () => 'Zone6HR', getValue: () => 0 }, { getType: () => 'Zone7HR', getValue: () => 0 }, + { getType: () => 'Zone1Power', getValue: () => 0 }, + { getType: () => 'Zone2Power', getValue: () => 0 }, + { getType: () => 'Zone3Power', getValue: () => 0 }, + { getType: () => 'Zone4Power', getValue: () => 0 }, + { getType: () => 'Zone5Power', getValue: () => 0 }, + { getType: () => 'Zone6Power', getValue: () => 0 }, + { getType: () => 'Zone7Power', getValue: () => 0 }, ] as any); }); @@ -102,6 +118,68 @@ describe('convertIntensityZonesStatsToChartData', () => { }); }); +describe('convertIntensityZonesStatsToEchartsData', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should keep deterministic zone ordering and include only active zones', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 0 }, + { getType: () => 'Zone3HR', getValue: () => 20 }, + { getType: () => 'Zone1Power', getValue: () => 0 }, + { getType: () => 'Zone2Power', getValue: () => 5 }, + { getType: () => 'Zone3Power', getValue: () => 15 }, + ] as any); + + const result = convertIntensityZonesStatsToEchartsData([]); + + expect(result.zones).toEqual(['Zone 1', 'Zone 2', 'Zone 3']); + expect(result.series.map(series => series.type)).toEqual(['Heart Rate', 'Power']); + expect(result.series[0].values).toEqual([10, 0, 20]); + expect(result.series[1].values).toEqual([0, 5, 15]); + }); + + it('should support short labels for mobile mode', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 20 }, + ] as any); + + const result = convertIntensityZonesStatsToEchartsData([], true); + + expect(result.zones).toEqual(['Z1', 'Z2']); + }); + + it('should compute percentages per active type exactly', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 20 }, + { getType: () => 'Zone1Power', getValue: () => 2 }, + { getType: () => 'Zone2Power', getValue: () => 6 }, + ] as any); + + const result = convertIntensityZonesStatsToEchartsData([]); + + expect(result.series[0].percentages).toEqual([33.33333333333333, 66.66666666666666]); + expect(result.series[1].percentages).toEqual([25, 75]); + }); + + it('should exclude inactive data types', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 0 }, + { getType: () => 'Zone1Power', getValue: () => 0 }, + { getType: () => 'Zone2Power', getValue: () => 0 }, + ] as any); + + const result = convertIntensityZonesStatsToEchartsData([]); + + expect(result.series.map(series => series.type)).toEqual(['Heart Rate']); + }); +}); + describe('getActiveDataTypes', () => { it('should return empty set for empty data', () => { expect(getActiveDataTypes([]).size).toBe(0); @@ -128,3 +206,42 @@ describe('getActiveDataTypes', () => { expect(result.size).toBe(0); }); }); + +describe('shouldRenderIntensityZonesChart', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return false when there is no active series', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 0 }, + { getType: () => 'Zone2HR', getValue: () => 0 }, + { getType: () => 'Zone1Power', getValue: () => 0 }, + { getType: () => 'Zone2Power', getValue: () => 0 }, + ] as any); + + expect(shouldRenderIntensityZonesChart([])).toBe(false); + }); + + it('should return false when all active data is in one zone only', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 0 }, + { getType: () => 'Zone1Power', getValue: () => 20 }, + { getType: () => 'Zone2Power', getValue: () => 0 }, + ] as any); + + expect(shouldRenderIntensityZonesChart([])).toBe(false); + }); + + it('should return true when data spans multiple zones', () => { + vi.mocked(ActivityUtilities.getIntensityZonesStatsAggregated).mockReturnValue([ + { getType: () => 'Zone1HR', getValue: () => 10 }, + { getType: () => 'Zone2HR', getValue: () => 5 }, + { getType: () => 'Zone1Power', getValue: () => 0 }, + { getType: () => 'Zone2Power', getValue: () => 12 }, + ] as any); + + expect(shouldRenderIntensityZonesChart([])).toBe(true); + }); +}); diff --git a/src/app/helpers/intensity-zones-chart-data-helper.ts b/src/app/helpers/intensity-zones-chart-data-helper.ts index 5ddc3d8da..e0753bdea 100644 --- a/src/app/helpers/intensity-zones-chart-data-helper.ts +++ b/src/app/helpers/intensity-zones-chart-data-helper.ts @@ -2,6 +2,25 @@ import { StatsClassInterface } from '@sports-alliance/sports-lib'; import { DynamicDataLoader } from '@sports-alliance/sports-lib'; import { ActivityUtilities } from '@sports-alliance/sports-lib'; +export interface IntensityZonesEChartsSeries { + type: string; + values: number[]; + percentages: number[]; +} + +export interface IntensityZonesEChartsData { + zones: string[]; + series: IntensityZonesEChartsSeries[]; +} + +function getZoneStatsValueMap(statsClassInstances: StatsClassInterface[]): Record { + return ActivityUtilities.getIntensityZonesStatsAggregated(statsClassInstances).reduce((map: Record, stat) => { + const value = stat.getValue(); + map[stat.getType()] = typeof value === 'number' ? value : 0; + return map; + }, {}); +} + /** * Converts intensity zones stats to chart data format. * @param statsClassInstances - Array of stats class instances @@ -11,10 +30,7 @@ export function convertIntensityZonesStatsToChartData( statsClassInstances: StatsClassInterface[], shortLabels: boolean = false ): any[] { - const statsTypeMap = ActivityUtilities.getIntensityZonesStatsAggregated(statsClassInstances).reduce((map: { [key: string]: number }, stat) => { - map[stat.getType()] = stat.getValue() as any; - return map; - }, {}) + const statsTypeMap = getZoneStatsValueMap(statsClassInstances); const zoneLabel = (num: number) => shortLabels ? `Z${num}` : `Zone ${num}`; @@ -33,6 +49,65 @@ export function convertIntensityZonesStatsToChartData( }, []); } +/** + * Converts intensity zone stats to deterministic series data for ECharts. + * Keeps zone and series ordering stable based on `DynamicDataLoader.zoneStatsTypeMap`. + */ +export function convertIntensityZonesStatsToEchartsData( + statsClassInstances: StatsClassInterface[], + shortLabels: boolean = false +): IntensityZonesEChartsData { + const statsTypeMap = getZoneStatsValueMap(statsClassInstances); + const zoneLabel = (num: number) => shortLabels ? `Z${num}` : `Zone ${num}`; + + const byType = DynamicDataLoader.zoneStatsTypeMap.map(statsToTypeMapEntry => ({ + type: statsToTypeMapEntry.type, + values: statsToTypeMapEntry.stats.map(statType => { + const value = statsTypeMap[statType]; + return typeof value === 'number' ? value : 0; + }) + })); + + const activeSeries = byType.filter(typeEntry => typeEntry.values.some(value => value > 0)); + const activeZoneIndexes = activeSeries.reduce((indexes: Set, typeEntry) => { + typeEntry.values.forEach((value, index) => { + if (value > 0) { + indexes.add(index); + } + }); + return indexes; + }, new Set()); + + const orderedZoneIndexes = [...activeZoneIndexes].sort((left, right) => left - right); + const zones = orderedZoneIndexes.map(index => zoneLabel(index + 1)); + + const series: IntensityZonesEChartsSeries[] = activeSeries.map(typeEntry => { + const values = orderedZoneIndexes.map(index => typeEntry.values[index] ?? 0); + const total = values.reduce((sum, value) => sum + value, 0); + const percentages = values.map(value => total > 0 ? (value / total) * 100 : 0); + + return { + type: typeEntry.type, + values, + percentages + }; + }); + + return { + zones, + series + }; +} + +/** + * Determines whether an intensity-zones chart is meaningful enough to render. + * Hides charts with no active series or where all active data collapses to a single zone. + */ +export function shouldRenderIntensityZonesChart(statsClassInstances: StatsClassInterface[]): boolean { + const data = convertIntensityZonesStatsToEchartsData(statsClassInstances); + return data.series.length > 0 && data.zones.length > 1; +} + /** * Scans the chart data to find which types have non-zero values. * @param data - The chart data returned by convertIntensityZonesStatsToChartData diff --git a/src/app/helpers/power-curve-chart-data-helper.spec.ts b/src/app/helpers/power-curve-chart-data-helper.spec.ts new file mode 100644 index 000000000..7bdd8c3b7 --- /dev/null +++ b/src/app/helpers/power-curve-chart-data-helper.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { ActivityInterface } from '@sports-alliance/sports-lib'; + +import { + buildPowerCurveSeries, + shouldRenderPowerCurveChart, +} from './power-curve-chart-data-helper'; + +const POWER_CURVE_TYPE = 'PowerCurve'; + +type RawPoint = { + duration?: unknown; + power?: unknown; + wattsPerKg?: unknown; +}; + +const valueObject = (value: unknown) => ({ + getValue: () => value, +}); + +function createActivity(options: { + id?: string; + creatorName?: string; + type?: string; + points?: RawPoint[] | null; +}): ActivityInterface { + const id = options.id ?? 'activity-1'; + const creatorName = options.creatorName ?? 'Device'; + const type = options.type ?? 'Ride'; + const points = options.points; + const powerCurveStat = points === null || points === undefined ? null : { + getValue: () => points, + }; + + return { + type, + creator: { name: creatorName }, + getID: () => id, + getStat: (statType: string) => { + if (statType === POWER_CURVE_TYPE) { + return powerCurveStat as any; + } + return null; + }, + } as unknown as ActivityInterface; +} + +describe('power-curve-chart-data-helper', () => { + it('should return false when no activities are provided', () => { + expect(shouldRenderPowerCurveChart([])).toBe(false); + }); + + it('should return false when no activity has valid power-curve points', () => { + const activities = [ + createActivity({ + id: 'a1', + points: [ + { duration: 0, power: 300 }, + { duration: 60, power: 0 }, + ], + }), + createActivity({ id: 'a2', points: null }), + ]; + + expect(shouldRenderPowerCurveChart(activities)).toBe(false); + }); + + it('should return true when at least one valid power-curve point exists', () => { + const activities = [ + createActivity({ + id: 'a1', + points: [ + { duration: 1, power: 900 }, + ], + }), + ]; + + expect(shouldRenderPowerCurveChart(activities)).toBe(true); + }); + + it('should normalize data objects and numeric values, filter invalid points, and sort durations', () => { + const activities = [ + createActivity({ + id: 'a1', + creatorName: 'Trainer', + points: [ + { duration: valueObject(60), power: valueObject(300), wattsPerKg: valueObject(4.0) }, + { duration: 60, power: 320, wattsPerKg: 4.2 }, + { duration: valueObject(15), power: valueObject(500) }, + { duration: '300', power: '280' }, + { duration: -1, power: 100 }, + { duration: 30, power: 0 }, + { duration: Number.NaN, power: 250 }, + ], + }), + ]; + + const result = buildPowerCurveSeries(activities); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Ride'); + expect(result[0].points).toEqual([ + { duration: 15, power: 500 }, + { duration: 60, power: 320, wattsPerKg: 4.2 }, + { duration: 300, power: 280 }, + ]); + }); + + it('should keep max power for duplicate durations', () => { + const activities = [ + createActivity({ + id: 'a1', + points: [ + { duration: 120, power: 280, wattsPerKg: 3.8 }, + { duration: 120, power: 310, wattsPerKg: 4.1 }, + { duration: 120, power: 305, wattsPerKg: 4.5 }, + ], + }), + ]; + + const result = buildPowerCurveSeries(activities); + + expect(result).toHaveLength(1); + expect(result[0].points).toEqual([ + { duration: 120, power: 310, wattsPerKg: 4.1 }, + ]); + }); + + it('should omit activities that do not provide valid points', () => { + const activities = [ + createActivity({ + id: 'a1', + creatorName: 'Valid Device', + points: [{ duration: 60, power: 300 }], + }), + createActivity({ + id: 'a2', + creatorName: 'Invalid Device', + points: [{ duration: 0, power: 200 }], + }), + ]; + + const result = buildPowerCurveSeries(activities); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Ride'); + }); + + it('should use sport labels for non-merge multi-activity charts', () => { + const activities = [ + createActivity({ id: 'a1', creatorName: 'Power Meter A', type: 'Run', points: [{ duration: 60, power: 300 }] }), + createActivity({ id: 'a2', creatorName: 'Power Meter B', type: 'Run', points: [{ duration: 60, power: 310 }] }), + createActivity({ id: 'a3', creatorName: 'Power Meter C', type: 'Bike', points: [{ duration: 60, power: 320 }] }), + ]; + + const result = buildPowerCurveSeries(activities); + + expect(result.map((series) => series.label)).toEqual([ + 'Run', + 'Run (2)', + 'Bike', + ]); + }); + + it('should suffix duplicate device labels deterministically for merge events', () => { + const activities = [ + createActivity({ id: 'a1', creatorName: 'Power Meter', points: [{ duration: 60, power: 300 }] }), + createActivity({ id: 'a2', creatorName: 'Power Meter', points: [{ duration: 60, power: 310 }] }), + createActivity({ id: 'a3', creatorName: 'Power Meter', points: [{ duration: 60, power: 320 }] }), + ]; + + const result = buildPowerCurveSeries(activities, { isMerge: true }); + + expect(result.map((series) => series.label)).toEqual([ + 'Power Meter', + 'Power Meter (2)', + 'Power Meter (3)', + ]); + }); + + it('should fallback to activity type then positional label when creator name is missing', () => { + const activities = [ + createActivity({ id: 'a1', creatorName: '', type: 'Run', points: [{ duration: 60, power: 280 }] }), + createActivity({ id: 'a2', creatorName: '', type: '', points: [{ duration: 120, power: 260 }] }), + ]; + + const result = buildPowerCurveSeries(activities); + + expect(result.map((series) => series.label)).toEqual(['Run', 'Activity 2']); + }); +}); diff --git a/src/app/helpers/power-curve-chart-data-helper.ts b/src/app/helpers/power-curve-chart-data-helper.ts new file mode 100644 index 000000000..8598015fb --- /dev/null +++ b/src/app/helpers/power-curve-chart-data-helper.ts @@ -0,0 +1,164 @@ +import { ActivityInterface } from '@sports-alliance/sports-lib'; + +const POWER_CURVE_TYPE = 'PowerCurve'; + +interface ValueObject { + getValue?: () => unknown; +} + +export interface PowerCurveChartPoint { + duration: number; + power: number; + wattsPerKg?: number; +} + +export interface PowerCurveChartSeries { + activity: ActivityInterface; + activityId: string; + label: string; + points: PowerCurveChartPoint[]; +} + +export interface BuildPowerCurveSeriesOptions { + isMerge?: boolean; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + } + + if (value && typeof value === 'object' && typeof (value as ValueObject).getValue === 'function') { + const numeric = (value as ValueObject).getValue?.(); + return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric : null; + } + + return null; +} + +function getRawPowerCurvePoints(activity: ActivityInterface): unknown[] { + const stat = activity?.getStat?.(POWER_CURVE_TYPE) as ValueObject | null | undefined; + const statValue = stat?.getValue?.(); + return Array.isArray(statValue) ? statValue : []; +} + +function getActivityTypeLabel(activity: ActivityInterface): string { + const activityType = `${(activity as { type?: unknown })?.type ?? ''}`.trim(); + if (activityType.length > 0) { + return activityType; + } + + return ''; +} + +function getActivityBaseLabel(activity: ActivityInterface, index: number, isMerge: boolean): string { + if (isMerge) { + const creatorName = `${activity?.creator?.name ?? ''}`.trim(); + if (creatorName.length > 0) { + return creatorName; + } + } + + const activityType = getActivityTypeLabel(activity); + if (activityType.length > 0) { + return activityType; + } + + if (!isMerge) { + const creatorName = `${activity?.creator?.name ?? ''}`.trim(); + if (creatorName.length > 0) { + return creatorName; + } + } + + return `Activity ${index + 1}`; +} + +function normalizePowerCurvePoints(rawPoints: unknown[]): PowerCurveChartPoint[] { + const byDuration = new Map(); + + rawPoints.forEach((rawPoint) => { + if (!rawPoint || typeof rawPoint !== 'object') { + return; + } + + const point = rawPoint as { duration?: unknown; power?: unknown; wattsPerKg?: unknown }; + const duration = toFiniteNumber(point.duration); + const power = toFiniteNumber(point.power); + const wattsPerKg = toFiniteNumber(point.wattsPerKg); + + if (!duration || duration <= 0 || !power || power <= 0) { + return; + } + + const normalizedDuration = Number(duration); + const normalizedPoint: PowerCurveChartPoint = { + duration: normalizedDuration, + power: Number(power), + }; + + if (wattsPerKg && wattsPerKg > 0) { + normalizedPoint.wattsPerKg = Number(wattsPerKg); + } + + const existingPoint = byDuration.get(normalizedDuration); + if (!existingPoint || normalizedPoint.power > existingPoint.power) { + byDuration.set(normalizedDuration, normalizedPoint); + return; + } + + if ( + normalizedPoint.power === existingPoint.power + && (normalizedPoint.wattsPerKg ?? 0) > (existingPoint.wattsPerKg ?? 0) + ) { + byDuration.set(normalizedDuration, normalizedPoint); + } + }); + + return [...byDuration.values()].sort((left, right) => left.duration - right.duration); +} + +export function buildPowerCurveSeries( + activities: ActivityInterface[], + options: BuildPowerCurveSeriesOptions = {} +): PowerCurveChartSeries[] { + if (!Array.isArray(activities) || activities.length === 0) { + return []; + } + + const isMerge = options.isMerge === true; + + const candidateSeries = activities.map((activity, index) => { + const points = normalizePowerCurvePoints(getRawPowerCurvePoints(activity)); + + return { + activity, + activityId: `${activity?.getID?.() ?? `activity-${index + 1}`}`, + baseLabel: getActivityBaseLabel(activity, index, isMerge), + points, + }; + }).filter((series) => series.points.length > 0); + + const labelCount = new Map(); + + return candidateSeries.map((series) => { + const count = (labelCount.get(series.baseLabel) ?? 0) + 1; + labelCount.set(series.baseLabel, count); + + return { + activity: series.activity, + activityId: series.activityId, + label: count === 1 ? series.baseLabel : `${series.baseLabel} (${count})`, + points: series.points, + }; + }); +} + +export function shouldRenderPowerCurveChart(activities: ActivityInterface[]): boolean { + return buildPowerCurveSeries(activities, { isMerge: false }).length > 0; +} diff --git a/src/app/helpers/stat-label.helper.spec.ts b/src/app/helpers/stat-label.helper.spec.ts new file mode 100644 index 000000000..447caaca0 --- /dev/null +++ b/src/app/helpers/stat-label.helper.spec.ts @@ -0,0 +1,49 @@ +import { DataPowerAvg, DataSpeedAvgKilometersPerHour } from '@sports-alliance/sports-lib'; +import { describe, expect, it } from 'vitest'; +import { normalizeUnitDerivedStatLabel, normalizeUnitDerivedTypeLabel } from './stat-label.helper'; + +describe('stat-label.helper', () => { + it('should normalize unit-derived type labels without qualifiers', () => { + expect(normalizeUnitDerivedTypeLabel('Speed in kilometers per hour')).toBe('Speed'); + expect(normalizeUnitDerivedTypeLabel('Distance in miles')).toBe('Distance'); + }); + + it('should normalize unit-derived labels while preserving avg/min/max qualifiers', () => { + expect(normalizeUnitDerivedTypeLabel('Average speed in kilometers per hour')).toBe('Average Speed'); + expect(normalizeUnitDerivedTypeLabel('minimum pace in minutes per mile')).toBe('Minimum Pace'); + expect(normalizeUnitDerivedTypeLabel('MAXIMUM speed in miles per hour')).toBe('Maximum Speed'); + expect(normalizeUnitDerivedTypeLabel('Average jump speed in kilometers per hour')).toBe('Average Jump Speed'); + expect(normalizeUnitDerivedTypeLabel('Minimum jump speed in miles per hour')).toBe('Minimum Jump Speed'); + }); + + it('should normalize unit-derived base family labels for jump speed variants', () => { + expect(normalizeUnitDerivedTypeLabel('jump speed in kilometers per hour')).toBe('Jump Speed'); + expect(normalizeUnitDerivedTypeLabel('Jump speed in knots')).toBe('Jump Speed'); + }); + + it('should use fallback label to disambiguate overlapping unit-derived variants', () => { + expect(normalizeUnitDerivedTypeLabel('Distance in miles', 'Jump Distance')).toBe('Jump Distance'); + expect(normalizeUnitDerivedTypeLabel('Distance in miles', 'GNSS Distance')).toBe('GNSS Distance'); + }); + + it('should leave non-unit-derived labels unchanged', () => { + expect(normalizeUnitDerivedTypeLabel(DataPowerAvg.type, 'Average Power')).toBe('Average Power'); + expect(normalizeUnitDerivedTypeLabel('VO2 Max')).toBe('VO2 Max'); + }); + + it('should fall back to provided display label for non-unit-derived stat instances', () => { + const mockStat = { + getType: () => DataPowerAvg.type, + getDisplayType: () => 'Average Power', + } as any; + expect(normalizeUnitDerivedStatLabel(mockStat)).toBe('Average Power'); + }); + + it('should normalize unit-derived stat instance labels using type identity', () => { + const mockStat = { + getType: () => DataSpeedAvgKilometersPerHour.type, + getDisplayType: () => 'Average speed in kilometers per hour', + } as any; + expect(normalizeUnitDerivedStatLabel(mockStat)).toBe('Average Speed'); + }); +}); diff --git a/src/app/helpers/stat-label.helper.ts b/src/app/helpers/stat-label.helper.ts new file mode 100644 index 000000000..ed4d28de1 --- /dev/null +++ b/src/app/helpers/stat-label.helper.ts @@ -0,0 +1,122 @@ +import { DataInterface, DynamicDataLoader } from '@sports-alliance/sports-lib'; + +type StatQualifierKey = 'average' | 'minimum' | 'maximum'; + +const STAT_QUALIFIER_REGEX = /^(average|minimum|maximum)\s+/i; +const STAT_QUALIFIER_LABELS: Record = { + average: 'Average', + minimum: 'Minimum', + maximum: 'Maximum', +}; + +const parseTypeQualifier = (type: string): { qualifier: StatQualifierKey | null; baseType: string } => { + const trimmedType = type.trim(); + const qualifierMatch = trimmedType.match(STAT_QUALIFIER_REGEX); + if (!qualifierMatch) { + return { qualifier: null, baseType: trimmedType }; + } + + const qualifier = qualifierMatch[1].toLowerCase() as StatQualifierKey; + const baseType = trimmedType.slice(qualifierMatch[0].length).trim(); + return { qualifier, baseType }; +}; + +const UNIT_DERIVED_PARENTS_BY_VARIANT = new Map>(); + +const addParentCandidate = (variantType: string, parentType: string): void => { + const normalizedVariantType = variantType.trim().toLowerCase(); + const normalizedParentType = parentType.trim(); + if (!normalizedVariantType.length || !normalizedParentType.length) { + return; + } + + const existingParents = UNIT_DERIVED_PARENTS_BY_VARIANT.get(normalizedVariantType) ?? new Set(); + existingParents.add(normalizedParentType); + UNIT_DERIVED_PARENTS_BY_VARIANT.set(normalizedVariantType, existingParents); +}; + +const addCandidatesFromUnitGroups = (): void => { + const unitGroups = DynamicDataLoader.dataTypeUnitGroups || {}; + Object.entries(unitGroups).forEach(([parentType, variants]) => { + const parentBaseType = parseTypeQualifier(parentType).baseType.trim(); + Object.keys(variants || {}).forEach((variantType) => { + const variantBaseType = parseTypeQualifier(variantType).baseType.trim(); + addParentCandidate(variantType, parentBaseType); + addParentCandidate(variantBaseType, parentBaseType); + }); + }); +}; + +const addCandidatesFromFamilyMaps = (familyMap?: Record): void => { + if (!familyMap) { + return; + } + + Object.entries(familyMap).forEach(([baseType, qualifiedType]) => { + const qualifiedBaseType = parseTypeQualifier(qualifiedType || '').baseType.trim().toLowerCase(); + const candidateParents = UNIT_DERIVED_PARENTS_BY_VARIANT.get(qualifiedBaseType); + if (!candidateParents?.size) { + return; + } + + candidateParents.forEach((candidateParent) => addParentCandidate(baseType, candidateParent)); + }); +}; + +const selectParentCandidate = (candidates: Set, fallbackLabel?: string): string => { + if (candidates.size <= 1) { + return [...candidates.values()][0]; + } + + const fallbackBaseType = parseTypeQualifier(fallbackLabel || '').baseType.trim().toLowerCase(); + if (fallbackBaseType.length) { + const exactFallbackMatch = [...candidates.values()] + .find((candidate) => candidate.toLowerCase() === fallbackBaseType); + if (exactFallbackMatch) { + return exactFallbackMatch; + } + } + + return [...candidates.values()] + .sort((a, b) => a.length - b.length || a.localeCompare(b))[0]; +}; + +addCandidatesFromUnitGroups(); +addCandidatesFromFamilyMaps(DynamicDataLoader.dataTypeAvgDataType || {}); +addCandidatesFromFamilyMaps(DynamicDataLoader.dataTypeMinDataType || {}); +addCandidatesFromFamilyMaps(DynamicDataLoader.dataTypeMaxDataType || {}); + +export const normalizeUnitDerivedTypeLabel = (type: string, fallbackLabel?: string): string => { + if (!type) { + return fallbackLabel ?? ''; + } + + const trimmedType = type.trim(); + if (!trimmedType.length) { + return fallbackLabel ?? ''; + } + + const { qualifier, baseType } = parseTypeQualifier(trimmedType); + const parentCandidates = UNIT_DERIVED_PARENTS_BY_VARIANT.get(baseType.toLowerCase()); + if (!parentCandidates?.size) { + return fallbackLabel ?? trimmedType; + } + + const parentType = selectParentCandidate(parentCandidates, fallbackLabel); + if (!qualifier) { + return parentType; + } + + const qualifierLabel = STAT_QUALIFIER_LABELS[qualifier]; + return `${qualifierLabel} ${parentType}`; +}; + +export const normalizeUnitDerivedStatLabel = (stat: DataInterface): string => { + if (!stat || typeof stat.getType !== 'function') { + return ''; + } + + const type = stat.getType(); + const fallbackLabel = typeof stat.getDisplayType === 'function' ? stat.getDisplayType() : type; + return normalizeUnitDerivedTypeLabel(type, fallbackLabel); +}; diff --git a/src/app/helpers/stats-diff.helper.spec.ts b/src/app/helpers/stats-diff.helper.spec.ts new file mode 100644 index 000000000..278f2c414 --- /dev/null +++ b/src/app/helpers/stats-diff.helper.spec.ts @@ -0,0 +1,185 @@ +import { + DataPowerAvg, + DataSpeedAvg, + DataSpeedAvgKilometersPerHour, + DynamicDataLoader, +} from '@sports-alliance/sports-lib'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildDiffMapForStats, buildStatDisplayList, computeStatDiff } from './stats-diff.helper'; + +const createStat = (type: string, displayType: string, value = 0, unit = '') => ({ + getType: () => type, + getDisplayType: () => displayType, + getDisplayValue: () => String(value), + getDisplayUnit: () => unit, + getValue: () => value, +}) as any; + +describe('stats-diff.helper', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should normalize unit-derived labels in buildStatDisplayList', () => { + const speedBaseStat = createStat(DataSpeedAvg.type, 'Average Speed'); + const speedKphStat = createStat( + DataSpeedAvgKilometersPerHour.type, + 'Average speed in kilometers per hour', + 30, + 'km/h' + ); + + vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance').mockReturnValue([speedKphStat] as any); + + const displayList = buildStatDisplayList( + [speedBaseStat], + [DataSpeedAvg.type], + {} as any + ); + + expect(displayList).toEqual([ + { + type: DataSpeedAvgKilometersPerHour.type, + label: 'Average Speed', + }, + ]); + }); + + it('should keep non-unit labels unchanged and dedupe by display type', () => { + const powerStat = createStat(DataPowerAvg.type, 'Average Power', 250, 'W'); + vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance').mockReturnValue([powerStat] as any); + + const displayList = buildStatDisplayList( + [powerStat], + [DataPowerAvg.type, DataPowerAvg.type], + {} as any + ); + + expect(displayList.length).toBe(1); + expect(displayList[0]).toEqual({ + type: DataPowerAvg.type, + label: 'Average Power', + }); + }); + + it('should compute diff when display stat exists directly on activities', () => { + const activityA = { + getStat: (type: string) => { + if (type === DataPowerAvg.type) { + return { getValue: () => 200 }; + } + return null; + }, + } as any; + + const activityB = { + getStat: (type: string) => { + if (type === DataPowerAvg.type) { + return { getValue: () => 100 }; + } + return null; + }, + } as any; + + const result = computeStatDiff( + activityA, + activityB, + DataPowerAvg.type, + DataPowerAvg.type, + {} as any + ); + + expect(result).toBeTruthy(); + expect(result?.percent).toBeCloseTo(66.666, 2); + expect((result?.display || '').toLowerCase()).toContain('watt'); + }); + + it('should compute diff via base-stat unit expansion fallback when display stat is missing', () => { + const baseStatA = { marker: 'a' }; + const baseStatB = { marker: 'b' }; + const speedKphA = { getType: () => DataSpeedAvgKilometersPerHour.type, getValue: () => 30 }; + const speedKphB = { getType: () => DataSpeedAvgKilometersPerHour.type, getValue: () => 20 }; + + const unitSpy = vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance') + .mockImplementation((stat: any) => { + if (stat?.marker === 'a') { + return [speedKphA] as any; + } + if (stat?.marker === 'b') { + return [speedKphB] as any; + } + return []; + }); + + const activityA = { + getStat: (type: string) => { + if (type === DataSpeedAvg.type) { + return baseStatA; + } + return null; + }, + } as any; + + const activityB = { + getStat: (type: string) => { + if (type === DataSpeedAvg.type) { + return baseStatB; + } + return null; + }, + } as any; + + const result = computeStatDiff( + activityA, + activityB, + DataSpeedAvg.type, + DataSpeedAvgKilometersPerHour.type, + {} as any + ); + + expect(unitSpy).toHaveBeenCalled(); + expect(result).toBeTruthy(); + expect(result?.percent).toBeCloseTo(40, 2); + expect(result?.display).toContain('km/h'); + }); + + it('should build diff map with unit-based display types', () => { + const speedBaseStat = createStat(DataSpeedAvg.type, 'Average Speed'); + const speedKphStat = createStat( + DataSpeedAvgKilometersPerHour.type, + 'Average speed in kilometers per hour', + 30, + 'km/h' + ); + + vi.spyOn(DynamicDataLoader, 'getUnitBasedDataFromDataInstance').mockReturnValue([speedKphStat] as any); + + const activityA = { + getStat: (type: string) => { + if (type === DataSpeedAvgKilometersPerHour.type) { + return { getValue: () => 30 }; + } + return null; + }, + } as any; + + const activityB = { + getStat: (type: string) => { + if (type === DataSpeedAvgKilometersPerHour.type) { + return { getValue: () => 27 }; + } + return null; + }, + } as any; + + const diffMap = buildDiffMapForStats( + [speedBaseStat], + [DataSpeedAvg.type], + [activityA, activityB], + {} as any + ); + + expect(diffMap.has(DataSpeedAvgKilometersPerHour.type)).toBe(true); + expect(diffMap.get(DataSpeedAvgKilometersPerHour.type)?.display).toContain('km/h'); + }); +}); diff --git a/src/app/helpers/stats-diff.helper.ts b/src/app/helpers/stats-diff.helper.ts index be8e4be29..43b5d6735 100644 --- a/src/app/helpers/stats-diff.helper.ts +++ b/src/app/helpers/stats-diff.helper.ts @@ -1,4 +1,5 @@ import { ActivityInterface, DataInterface, DynamicDataLoader, UserUnitSettingsInterface } from '@sports-alliance/sports-lib'; +import { normalizeUnitDerivedTypeLabel } from './stat-label.helper'; export interface StatDiffResult { display: string; @@ -93,7 +94,10 @@ export const buildStatDisplayList = ( return; } seen.add(displayType); - displayList.push({ type: displayType, label: unitStat.getDisplayType() }); + displayList.push({ + type: displayType, + label: normalizeUnitDerivedTypeLabel(displayType, unitStat.getDisplayType()), + }); }); }); diff --git a/src/app/helpers/summary-metric-tabs.helper.spec.ts b/src/app/helpers/summary-metric-tabs.helper.spec.ts new file mode 100644 index 000000000..b6c639d8e --- /dev/null +++ b/src/app/helpers/summary-metric-tabs.helper.spec.ts @@ -0,0 +1,437 @@ +import { + DataAbsolutePressure, + DataAvgFlow, + DataAirPower, + DataJumpCount, + DataJumpDistance, + DataJumpDistanceAvg, + DataJumpDistanceMax, + DataJumpDistanceMin, + DataJumpHangTimeAvg, + DataJumpHangTimeMax, + DataJumpHangTimeMin, + DataJumpHeightAvg, + DataJumpHeightMax, + DataJumpHeightMin, + DataJumpRotationsAvg, + DataJumpRotationsMax, + DataJumpRotationsMin, + DataJumpScoreAvg, + DataJumpScoreMax, + DataJumpScoreMin, + DataJumpSpeedAvg, + DataJumpSpeedMax, + DataJumpSpeedMin, + DataStore, + DataEnergy, + DataDuration, + DataGradeAdjustedPaceAvg, + DataGradeAdjustedSpeedAvg, + DataHeartRateAvg, + DataHeartRateMax, + DataHeartRateMin, + DataPaceAvg, + DataPowerAvg, + DataRecoveryTime, + DataRPE, + DataSpeedAvg, + DataSwimPaceAvg, + DataVerticalSpeedMax, + DataVO2Max, +} from '@sports-alliance/sports-lib'; +import { describe, expect, it } from 'vitest'; +import { buildSummaryMetricTabs } from './summary-metric-tabs.helper'; + +describe('buildSummaryMetricTabs', () => { + it('should return tabs in configured group order', () => { + const tabs = buildSummaryMetricTabs([ + DataPowerAvg.type, + DataRPE.type, + DataDuration.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance', 'physiological']); + expect(tabs[0].metricTypes).toEqual([DataDuration.type, DataPowerAvg.type]); + expect(tabs[1].metricTypes).toEqual([DataPowerAvg.type]); + expect(tabs[2].metricTypes).toEqual([DataRPE.type]); + }); + + it('should remove empty groups', () => { + const tabs = buildSummaryMetricTabs([DataPowerAvg.type]); + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance']); + }); + + it('should send unknown metric types to Other', () => { + const tabs = buildSummaryMetricTabs(['Custom Stat']); + expect(tabs.map((tab) => tab.id)).toEqual(['other']); + expect(tabs[0].metricTypes).toEqual(['Custom Stat']); + }); + + it('should keep overall metrics in configured order', () => { + const tabs = buildSummaryMetricTabs([ + DataPowerAvg.type, + DataDuration.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance']); + expect(tabs[0].metricTypes).toEqual([DataDuration.type, DataPowerAvg.type]); + expect(tabs[1].metricTypes).toEqual([DataPowerAvg.type]); + }); + + it('should keep performance tab speed metric order and configured single-value overrides', () => { + const tabs = buildSummaryMetricTabs([ + DataVerticalSpeedMax.type, + DataGradeAdjustedSpeedAvg.type, + DataGradeAdjustedPaceAvg.type, + DataPaceAvg.type, + DataSpeedAvg.type, + DataSwimPaceAvg.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance']); + const performanceTab = tabs.find((tab) => tab.id === 'performance'); + expect(performanceTab?.metricTypes).toEqual([ + DataSpeedAvg.type, + DataPaceAvg.type, + DataSwimPaceAvg.type, + DataGradeAdjustedPaceAvg.type, + DataGradeAdjustedSpeedAvg.type, + DataVerticalSpeedMax.type, + ]); + expect(performanceTab?.singleValueTypes).toEqual([DataAvgFlow.type]); + }); + + it('should map extended power types and keep removed speed/power zone durations in Other', () => { + const tabs = buildSummaryMetricTabs([ + 'Power Normalized', + 'CriticalPower', + 'Power Training Stress Score', + 'Power Zone Four Duration', + 'Speed Zone Two Duration', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance', 'other']); + + const overallTab = tabs.find((tab) => tab.id === 'overall'); + expect(overallTab?.metricTypes).toEqual([ + 'Power Normalized', + ]); + + const performanceTab = tabs.find((tab) => tab.id === 'performance'); + expect(performanceTab?.metricTypes).toEqual([ + 'Power Normalized', + 'Power Training Stress Score', + 'CriticalPower', + ]); + + const otherTab = tabs.find((tab) => tab.id === 'other'); + expect(otherTab?.metricTypes).toEqual([ + 'Power Zone Four Duration', + 'Speed Zone Two Duration', + ]); + }); + + it('should map ascent and descent time into environment tab', () => { + const tabs = buildSummaryMetricTabs([ + 'Ascent Time', + 'Descent Time', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['environment']); + expect(tabs[0].metricTypes).toEqual([ + 'Ascent Time', + 'Descent Time', + ]); + }); + + it('should copy recovery/vo2 to overall and calories to physiological', () => { + const tabs = buildSummaryMetricTabs([ + DataRecoveryTime.type, + DataVO2Max.type, + DataEnergy.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'physiological']); + expect(tabs[0].metricTypes).toEqual([ + DataRecoveryTime.type, + DataVO2Max.type, + ]); + expect(tabs[1].metricTypes).toEqual([ + DataEnergy.type, + DataVO2Max.type, + DataRecoveryTime.type, + ]); + }); + + it('should map requested extras into physiological, environment, and performance tabs', () => { + const tabs = buildSummaryMetricTabs([ + DataStore.DataAge.type, + DataStore.DataGender.type, + DataStore.DataHeight.type, + DataStore.DataWeight.type, + DataStore.DataFitnessAge.type, + DataAbsolutePressure.type, + DataAirPower.type, + DataStore.DataEffortPace.type, + DataStore.DataAvgVAM.type, + DataStore.DataFormPower.type, + DataStore.DataEPOC.type, + DataStore.DataJumpCount.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual([ + 'performance', + 'environment', + 'physiological', + ]); + + const performanceTab = tabs.find((tab) => tab.id === 'performance'); + expect(performanceTab?.metricTypes).toEqual([ + DataAirPower.type, + DataStore.DataEffortPace.type, + DataStore.DataAvgVAM.type, + DataStore.DataEPOC.type, + DataStore.DataJumpCount.type, + DataStore.DataFormPower.type, + ]); + + const environmentTab = tabs.find((tab) => tab.id === 'environment'); + expect(environmentTab?.metricTypes).toEqual([DataAbsolutePressure.type]); + + const physiologicalTab = tabs.find((tab) => tab.id === 'physiological'); + expect(physiologicalTab?.metricTypes).toEqual([ + DataStore.DataWeight.type, + DataStore.DataHeight.type, + DataStore.DataGender.type, + DataStore.DataFitnessAge.type, + DataStore.DataAge.type, + ]); + }); + + it('should map device metrics into the new device tab', () => { + const tabs = buildSummaryMetricTabs([ + 'Battery Charge', + 'Battery Consumption', + 'Battery Current', + 'Battery Voltage', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['device']); + expect(tabs[0].metricTypes).toEqual([ + 'Battery Charge', + 'Battery Consumption', + 'Battery Current', + 'Battery Voltage', + ]); + }); + + it('should map new environment grade and pressure families', () => { + const tabs = buildSummaryMetricTabs([ + 'Absolute Pressure', + 'Average Absolute Pressure', + 'Minimum Absolute Pressure', + 'Maximum Absolute Pressure', + 'Grade', + 'Average Grade', + 'Minimum Grade', + 'Maximum Grade', + 'Distance (Stryd)', + 'GNSS Distance', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['environment']); + + const environmentTab = tabs.find((tab) => tab.id === 'environment'); + expect(environmentTab?.metricTypes).toEqual([ + 'Absolute Pressure', + 'Average Absolute Pressure', + 'Minimum Absolute Pressure', + 'Maximum Absolute Pressure', + 'Grade', + 'Average Grade', + 'Minimum Grade', + 'Maximum Grade', + 'Distance (Stryd)', + 'GNSS Distance', + ]); + + }); + + it('should map requested performance run-dynamics metrics', () => { + const tabs = buildSummaryMetricTabs([ + 'Form Power', + 'Average Ground Contact Time', + 'Minimum Ground Contact Time', + 'Maximum Ground Contact Time', + 'Stance Time Balance Left', + 'Vertical Oscillation', + 'Vertical Ratio', + 'Average Vertical Ratio', + 'Minimum Vertical Ratio', + 'Maximum Vertical Ratio', + 'Leg Stiffness', + 'Average Leg Stiffness', + 'Minimum Leg Stiffness', + 'Maximum Leg Stiffness', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['performance']); + expect(tabs[0].metricTypes).toEqual([ + 'Average Ground Contact Time', + 'Minimum Ground Contact Time', + 'Maximum Ground Contact Time', + 'Stance Time Balance Left', + 'Vertical Oscillation', + 'Vertical Ratio', + 'Average Vertical Ratio', + 'Minimum Vertical Ratio', + 'Maximum Vertical Ratio', + 'Leg Stiffness', + 'Average Leg Stiffness', + 'Minimum Leg Stiffness', + 'Maximum Leg Stiffness', + 'Form Power', + ]); + }); + + it('should map EVPE, EHPE and satellite families to device tab', () => { + const tabs = buildSummaryMetricTabs([ + 'EVPE', + 'Average EVPE', + 'Minimum EVPE', + 'Maximum EVPE', + 'EHPE', + 'Average EHPE', + 'Minimum EHPE', + 'Maximum EHPE', + 'Satellite 5 Best SNR', + 'Average Satellite 5 Best SNR', + 'Minimum Satellite 5 Best SNR', + 'Maximum Satellite 5 Best SNR', + 'Number of Satellites', + 'Average Number of Satellites', + 'Minimum Number of Satellites', + 'Maximum Number of Satellites', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['device']); + expect(tabs[0].metricTypes).toEqual([ + 'EVPE', + 'Average EVPE', + 'Minimum EVPE', + 'Maximum EVPE', + 'EHPE', + 'Average EHPE', + 'Minimum EHPE', + 'Maximum EHPE', + 'Satellite 5 Best SNR', + 'Average Satellite 5 Best SNR', + 'Minimum Satellite 5 Best SNR', + 'Maximum Satellite 5 Best SNR', + 'Number of Satellites', + 'Average Number of Satellites', + 'Minimum Number of Satellites', + 'Maximum Number of Satellites', + ]); + }); + + it('should keep physiological metrics and also expose heart rate in performance', () => { + const tabs = buildSummaryMetricTabs([ + DataHeartRateAvg.type, + DataHeartRateMax.type, + DataHeartRateMin.type, + DataEnergy.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['overall', 'performance', 'physiological']); + + const performanceTab = tabs.find((tab) => tab.id === 'performance'); + expect(performanceTab?.metricTypes).toEqual([ + DataHeartRateAvg.type, + DataHeartRateMax.type, + DataHeartRateMin.type, + ]); + + const physiologicalTab = tabs.find((tab) => tab.id === 'physiological'); + expect(physiologicalTab?.metricTypes).toEqual([ + DataEnergy.type, + ]); + }); + + it('should map aerobic and anaerobic training effect to physiological', () => { + const tabs = buildSummaryMetricTabs([ + 'Aerobic Training Effect', + 'Anaerobic Training Effect', + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['physiological']); + expect(tabs[0].metricTypes).toEqual([ + 'Aerobic Training Effect', + 'Anaerobic Training Effect', + ]); + }); + + it('should map respiration rate family to physiological tab', () => { + const tabs = buildSummaryMetricTabs([ + DataStore.DataAvgRespirationRate.type, + DataStore.DataMinRespirationRate.type, + DataStore.DataMaxRespirationRate.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['physiological']); + expect(tabs[0].metricTypes).toEqual([ + DataStore.DataAvgRespirationRate.type, + DataStore.DataMinRespirationRate.type, + DataStore.DataMaxRespirationRate.type, + ]); + }); + + it('should map jump metrics to performance in configured order', () => { + const tabs = buildSummaryMetricTabs([ + DataJumpScoreMax.type, + DataJumpHeightMin.type, + DataJumpCount.type, + DataJumpDistance.type, + DataJumpDistanceMax.type, + DataJumpDistanceMin.type, + DataJumpDistanceAvg.type, + DataJumpHangTimeMax.type, + DataJumpHangTimeMin.type, + DataJumpHangTimeAvg.type, + DataJumpHeightMax.type, + DataJumpHeightAvg.type, + DataJumpSpeedMax.type, + DataJumpSpeedMin.type, + DataJumpSpeedAvg.type, + DataJumpRotationsMax.type, + DataJumpRotationsMin.type, + DataJumpRotationsAvg.type, + DataJumpScoreMin.type, + DataJumpScoreAvg.type, + ]); + + expect(tabs.map((tab) => tab.id)).toEqual(['performance']); + expect(tabs[0].metricTypes).toEqual([ + DataJumpCount.type, + DataJumpDistance.type, + DataJumpDistanceAvg.type, + DataJumpDistanceMin.type, + DataJumpDistanceMax.type, + DataJumpHangTimeAvg.type, + DataJumpHangTimeMin.type, + DataJumpHangTimeMax.type, + DataJumpHeightAvg.type, + DataJumpHeightMin.type, + DataJumpHeightMax.type, + DataJumpSpeedAvg.type, + DataJumpSpeedMin.type, + DataJumpSpeedMax.type, + DataJumpRotationsAvg.type, + DataJumpRotationsMin.type, + DataJumpRotationsMax.type, + DataJumpScoreAvg.type, + DataJumpScoreMin.type, + DataJumpScoreMax.type, + ]); + }); +}); diff --git a/src/app/helpers/summary-metric-tabs.helper.ts b/src/app/helpers/summary-metric-tabs.helper.ts new file mode 100644 index 000000000..bb3edd419 --- /dev/null +++ b/src/app/helpers/summary-metric-tabs.helper.ts @@ -0,0 +1,80 @@ +import { + EVENT_SUMMARY_METRIC_GROUPS, + EventSummaryMetricGroupConfig, + EventSummaryMetricGroupId, +} from '../constants/event-summary-metric-groups'; + +export interface SummaryMetricTab { + id: EventSummaryMetricGroupId; + label: string; + metricTypes: string[]; + singleValueTypes?: string[]; +} + +const OTHER_GROUP_ID: EventSummaryMetricGroupId = 'other'; +const OVERALL_GROUP_ID: EventSummaryMetricGroupId = 'overall'; + +export const buildSummaryMetricTabs = (resolvedMetricTypes: string[]): SummaryMetricTab[] => { + if (!resolvedMetricTypes.length) { + return []; + } + + const uniqueMetricTypes = [...new Set(resolvedMetricTypes).values()]; + const uniqueMetricTypeSet = new Set(uniqueMetricTypes); + + const configuredGroups = EVENT_SUMMARY_METRIC_GROUPS.filter((group) => group.id !== OTHER_GROUP_ID); + const knownConfiguredMetricTypes = new Set(); + configuredGroups.forEach((group) => { + group.metricTypes.forEach((metricType) => knownConfiguredMetricTypes.add(metricType)); + }); + + const overallGroup = EVENT_SUMMARY_METRIC_GROUPS.find((group) => group.id === OVERALL_GROUP_ID); + const tabsMap = new Map(); + EVENT_SUMMARY_METRIC_GROUPS.forEach((group) => { + tabsMap.set(group.id, { + id: group.id, + label: group.label, + metricTypes: [], + singleValueTypes: group.singleValueTypes || [], + }); + }); + + if (overallGroup) { + const overallTab = tabsMap.get(overallGroup.id); + if (overallTab) { + overallGroup.metricTypes.forEach((metricType) => { + if (uniqueMetricTypeSet.has(metricType)) { + overallTab.metricTypes.push(metricType); + } + }); + } + } + + EVENT_SUMMARY_METRIC_GROUPS.forEach((group: EventSummaryMetricGroupConfig) => { + if (group.id === OVERALL_GROUP_ID || group.id === OTHER_GROUP_ID) { + return; + } + const tab = tabsMap.get(group.id); + if (!tab) { + return; + } + group.metricTypes.forEach((metricType) => { + if (uniqueMetricTypeSet.has(metricType)) { + tab.metricTypes.push(metricType); + } + }); + }); + + const otherTab = tabsMap.get(OTHER_GROUP_ID); + if (otherTab) { + uniqueMetricTypes.forEach((metricType) => { + if (!knownConfiguredMetricTypes.has(metricType)) { + otherTab.metricTypes.push(metricType); + } + }); + } + + return EVENT_SUMMARY_METRIC_GROUPS + .map((group) => tabsMap.get(group.id)) + .filter((tab): tab is SummaryMetricTab => !!tab && tab.metricTypes.length > 0); +}; diff --git a/src/app/helpers/summary-stats.helper.spec.ts b/src/app/helpers/summary-stats.helper.spec.ts new file mode 100644 index 000000000..881c7731b --- /dev/null +++ b/src/app/helpers/summary-stats.helper.spec.ts @@ -0,0 +1,146 @@ +import { + ActivityTypes, + DataStore, + DataAscent, + DataCadenceMin, + DataDescent, + DataFeeling, + DataGradeAdjustedPaceAvg, + DataGradeAdjustedSpeedAvg, + DataHeartRateMin, + DataJumpCount, + DataJumpDistanceAvg, + DataJumpHeightAvg, + DataJumpRotationsMin, + DataJumpScoreAvg, + DataJumpSpeedMax, + DataPaceAvg, + DataPowerMax, + DataRPE, + DataSpeedAvg, + DataTemperatureMax, +} from '@sports-alliance/sports-lib'; +import { describe, expect, it } from 'vitest'; +import { getDefaultSummaryStatTypes } from './summary-stats.helper'; + +describe('getDefaultSummaryStatTypes', () => { + it('should include expanded default metrics from constants', () => { + const stats = getDefaultSummaryStatTypes([ActivityTypes.Cycling]); + + expect(stats).toContain(DataPowerMax.type); + expect(stats).toContain(DataCadenceMin.type); + expect(stats).toContain(DataTemperatureMax.type); + expect(stats).toContain(DataHeartRateMin.type); + expect(stats).toContain(DataFeeling.type); + expect(stats).toContain(DataRPE.type); + expect(stats).toContain('Power Normalized'); + expect(stats).toContain('Power Training Stress Score'); + expect(stats).toContain('Ascent Time'); + expect(stats).toContain('Descent Time'); + expect(stats).toContain('Average Absolute Pressure'); + expect(stats).toContain('Average Grade'); + expect(stats).toContain('Average Ground Contact Time'); + expect(stats).toContain('Average Leg Stiffness'); + expect(stats).toContain('Average EVPE'); + expect(stats).toContain('Average EHPE'); + expect(stats).toContain(DataJumpCount.type); + expect(stats).toContain(DataJumpDistanceAvg.type); + expect(stats).toContain(DataJumpHeightAvg.type); + expect(stats).toContain(DataJumpSpeedMax.type); + expect(stats).toContain(DataJumpRotationsMin.type); + expect(stats).toContain(DataJumpScoreAvg.type); + expect(stats).toContain(DataStore.DataAvgVAM.type); + expect(stats).toContain(DataStore.DataAvgRespirationRate.type); + expect(stats).toContain(DataStore.DataMinRespirationRate.type); + expect(stats).toContain(DataStore.DataMaxRespirationRate.type); + expect(stats).toContain(DataStore.DataFitnessAge.type); + expect(stats).toContain(DataStore.DataAnaerobicTrainingEffect.type); + expect(stats).toContain(DataStore.DataGender.type); + expect(stats).toContain(DataStore.DataHeight.type); + expect(stats).toContain(DataStore.DataWeight.type); + }); + + it('should keep speed derivation behavior by activity type', () => { + const runningStats = getDefaultSummaryStatTypes([ActivityTypes.Running]); + const cyclingStats = getDefaultSummaryStatTypes([ActivityTypes.Cycling]); + + expect(runningStats).toContain(DataPaceAvg.type); + expect(runningStats).toContain(DataGradeAdjustedPaceAvg.type); + expect(runningStats).toContain('Minimum Grade Adjusted Pace'); + expect(runningStats).toContain('Maximum Grade Adjusted Pace'); + expect(runningStats).not.toContain(DataGradeAdjustedSpeedAvg.type); + expect(runningStats).not.toContain('Minimum Grade Adjusted Speed'); + expect(runningStats).not.toContain(DataSpeedAvg.type); + expect(cyclingStats).toContain(DataSpeedAvg.type); + expect(cyclingStats).toContain(DataGradeAdjustedSpeedAvg.type); + expect(cyclingStats).toContain('Minimum Grade Adjusted Speed'); + expect(cyclingStats).toContain('Maximum Grade Adjusted Speed'); + expect(cyclingStats).not.toContain(DataGradeAdjustedPaceAvg.type); + expect(cyclingStats).not.toContain('Minimum Grade Adjusted Pace'); + }); + + it('should prefer pace-derived families over speed when activity exposes both', () => { + const stats = getDefaultSummaryStatTypes([ActivityTypes.TrailRunning]); + + expect(stats).toContain(DataGradeAdjustedPaceAvg.type); + expect(stats).toContain('Minimum Grade Adjusted Pace'); + expect(stats).toContain('Maximum Grade Adjusted Pace'); + expect(stats).not.toContain(DataSpeedAvg.type); + expect(stats).not.toContain(DataGradeAdjustedSpeedAvg.type); + expect(stats).not.toContain('Minimum Grade Adjusted Speed'); + expect(stats).not.toContain('Maximum Grade Adjusted Speed'); + }); + + it('should normalize raw running aliases to running pace families', () => { + const stats = getDefaultSummaryStatTypes(['running' as unknown as ActivityTypes]); + + expect(stats).toContain(DataPaceAvg.type); + expect(stats).toContain(DataGradeAdjustedPaceAvg.type); + expect(stats).toContain('Minimum Grade Adjusted Pace'); + expect(stats).toContain('Maximum Grade Adjusted Pace'); + expect(stats).not.toContain(DataSpeedAvg.type); + expect(stats).not.toContain(DataGradeAdjustedSpeedAvg.type); + expect(stats).not.toContain('Minimum Grade Adjusted Speed'); + }); + + it('should normalize non-canonical running strings (case/whitespace) to pace families', () => { + const stats = getDefaultSummaryStatTypes([' RUNNING ' as unknown as ActivityTypes]); + + expect(stats).toContain(DataPaceAvg.type); + expect(stats).toContain(DataGradeAdjustedPaceAvg.type); + expect(stats).toContain('Minimum Grade Adjusted Pace'); + expect(stats).not.toContain(DataGradeAdjustedSpeedAvg.type); + expect(stats).not.toContain('Minimum Grade Adjusted Speed'); + }); + + it('should normalize raw trail aliases to mixed speed/pace families', () => { + const stats = getDefaultSummaryStatTypes(['running_trail' as unknown as ActivityTypes]); + + expect(stats).toContain(DataPaceAvg.type); + expect(stats).toContain(DataGradeAdjustedPaceAvg.type); + expect(stats).toContain('Minimum Grade Adjusted Pace'); + expect(stats).not.toContain(DataSpeedAvg.type); + expect(stats).not.toContain(DataGradeAdjustedSpeedAvg.type); + expect(stats).not.toContain('Minimum Grade Adjusted Speed'); + }); + + it('should still exclude ascent and descent when manually configured', () => { + const stats = getDefaultSummaryStatTypes([ActivityTypes.Cycling], { + removeAscentForEventTypes: [ActivityTypes.Cycling], + removeDescentForEventTypes: [ActivityTypes.Cycling], + }); + + expect(stats).not.toContain(DataAscent.type); + expect(stats).not.toContain(DataDescent.type); + }); + + it('should exclude ascent and descent for non-canonical configured activity types', () => { + const stats = getDefaultSummaryStatTypes([ActivityTypes.Cycling], { + removeAscentForEventTypes: [' cycling '], + removeDescentForEventTypes: ['CYCLING'], + }); + + expect(stats).not.toContain(DataAscent.type); + expect(stats).not.toContain(DataDescent.type); + }); +}); diff --git a/src/app/helpers/summary-stats.helper.ts b/src/app/helpers/summary-stats.helper.ts index 68e6331db..43734a8d1 100644 --- a/src/app/helpers/summary-stats.helper.ts +++ b/src/app/helpers/summary-stats.helper.ts @@ -1,24 +1,19 @@ import { ActivityTypes, ActivityTypesHelper, - DataAerobicTrainingEffect, - DataAltitudeMax, - DataAltitudeMin, DataAscent, - DataCadenceAvg, DataDescent, - DataDistance, - DataDuration, - DataEnergy, - DataHeartRateAvg, - DataMovingTime, - DataPeakEPOC, - DataPowerAvg, - DataRecoveryTime, + DataGradeAdjustedPaceAvg, + DataGradeAdjustedSpeedAvg, + DataPaceAvg, DataSpeedAvg, - DataTemperatureAvg, - DataVO2Max, + DataSwimPaceAvg, } from '@sports-alliance/sports-lib'; +import { + EVENT_SUMMARY_DEFAULT_STAT_TYPES, + EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES, + EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES, +} from '../constants/event-summary-metric-groups'; import { AppEventUtilities } from '../utils/app.event.utilities'; export interface SummaryStatsSettingsLike { @@ -26,54 +21,134 @@ export interface SummaryStatsSettingsLike { removeDescentForEventTypes?: string[]; } +const ACTIVITY_TYPE_KEY_TO_CANONICAL = Object.keys(ActivityTypes).reduce((acc, key) => { + const canonical = ActivityTypes[key as keyof typeof ActivityTypes]; + if (!canonical || typeof canonical !== 'string') { + return acc; + } + acc.set(key.trim().toLowerCase(), canonical as ActivityTypes); + return acc; +}, new Map()); + +const ACTIVITY_TYPE_VALUE_TO_CANONICAL = Array + .from(new Set(Object.values(ActivityTypes) as ActivityTypes[]).values()) + .reduce((acc, value) => { + acc.set(value.trim().toLowerCase(), value as ActivityTypes); + return acc; + }, new Map()); + +const normalizeActivityType = (activityType: ActivityTypes): ActivityTypes => { + if (typeof activityType !== 'string') { + return activityType; + } + const normalizedInput = activityType.trim(); + if (!normalizedInput) { + return activityType; + } + + const exactKeyMatch = ActivityTypes[normalizedInput as keyof typeof ActivityTypes]; + if (exactKeyMatch) { + return exactKeyMatch as ActivityTypes; + } + + const normalizedLookupKey = normalizedInput.toLowerCase(); + return ACTIVITY_TYPE_KEY_TO_CANONICAL.get(normalizedLookupKey) + || ACTIVITY_TYPE_VALUE_TO_CANONICAL.get(normalizedLookupKey) + || activityType; +}; + +const toNormalizedActivityTypeLookupKey = (activityType: string): string => { + const normalizedType = normalizeActivityType(activityType as ActivityTypes); + return typeof normalizedType === 'string' ? normalizedType.trim().toLowerCase() : ''; +}; + +const resolvePreferredSpeedDerivedAverageTypesForActivity = (activityType: ActivityTypes): string[] => { + const metrics = ActivityTypesHelper.averageSpeedDerivedDataTypesToUseForActivityType(activityType) || []; + const hasPaceMetric = metrics.includes(DataPaceAvg.type); + const hasSwimPaceMetric = metrics.includes(DataSwimPaceAvg.type); + + // Intentional app-side exception vs sports-lib default derived families: + // when both pace/swim-pace and speed are available (e.g. Trail Running), + // summary defaults keep pace-family metrics and suppress speed-family defaults. + if (hasPaceMetric || hasSwimPaceMetric) { + return metrics.filter((type) => { + return type !== DataSpeedAvg.type && type !== DataGradeAdjustedSpeedAvg.type; + }); + } + + return metrics; +}; + export const getDefaultSummaryStatTypes = ( activityTypes: ActivityTypes[], summariesSettings?: SummaryStatsSettingsLike | null ): string[] => { - const statsToShow = [ - DataDuration.type, - DataMovingTime.type, - DataDistance.type, - DataSpeedAvg.type, - DataEnergy.type, - DataHeartRateAvg.type, - DataCadenceAvg.type, - DataPowerAvg.type, - DataAscent.type, - DataDescent.type, - DataAltitudeMax.type, - DataAltitudeMin.type, - DataRecoveryTime.type, - DataPeakEPOC.type, - DataAerobicTrainingEffect.type, - DataVO2Max.type, - DataTemperatureAvg.type, - ]; + const normalizedActivityTypes = activityTypes + .map((activityType) => normalizeActivityType(activityType)); + const normalizedActivityTypeLookupKeys = new Set( + normalizedActivityTypes + .map((activityType) => toNormalizedActivityTypeLookupKey(activityType)) + .filter((activityType) => !!activityType) + ); + const configuredAscentExclusionKeys = new Set( + (summariesSettings?.removeAscentForEventTypes || []) + .map((activityType) => toNormalizedActivityTypeLookupKey(activityType)) + .filter((activityType) => !!activityType) + ); + const configuredDescentExclusionKeys = new Set( + (summariesSettings?.removeDescentForEventTypes || []) + .map((activityType) => toNormalizedActivityTypeLookupKey(activityType)) + .filter((activityType) => !!activityType) + ); + const shouldExcludeAscentFromSettings = Array + .from(configuredAscentExclusionKeys) + .some((activityType) => normalizedActivityTypeLookupKeys.has(activityType)); + const shouldExcludeDescentFromSettings = Array + .from(configuredDescentExclusionKeys) + .some((activityType) => normalizedActivityTypeLookupKeys.has(activityType)); - return statsToShow.reduce((statsAccu: string[], statType: string) => { + const speedDerivedAverageTypes = normalizedActivityTypes.reduce((speedMetricsAccu: string[], activityType: ActivityTypes) => { + const metrics = resolvePreferredSpeedDerivedAverageTypesForActivity(activityType); + return [...new Set([...speedMetricsAccu, ...(metrics || [])]).values()]; + }, [] as string[]); + + const hasSpeedActivity = speedDerivedAverageTypes.includes(DataSpeedAvg.type) + || speedDerivedAverageTypes.includes(DataGradeAdjustedSpeedAvg.type); + const hasPaceActivity = speedDerivedAverageTypes.includes(DataPaceAvg.type) + || speedDerivedAverageTypes.includes(DataGradeAdjustedPaceAvg.type); + const gradeAdjustedSpeedSet = new Set(EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES); + const gradeAdjustedPaceSet = new Set(EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES); + + return EVENT_SUMMARY_DEFAULT_STAT_TYPES.reduce((statsAccu: string[], statType: string) => { if (statType === DataAscent.type) { if ( - AppEventUtilities.shouldExcludeAscent(activityTypes) - || (summariesSettings?.removeAscentForEventTypes || []).some((type: string) => (activityTypes as string[]).includes(type)) + AppEventUtilities.shouldExcludeAscent(normalizedActivityTypes) + || shouldExcludeAscentFromSettings ) { return statsAccu; } } if (statType === DataDescent.type) { if ( - AppEventUtilities.shouldExcludeDescent(activityTypes) - || (summariesSettings?.removeDescentForEventTypes || []).some((type: string) => (activityTypes as string[]).includes(type)) + AppEventUtilities.shouldExcludeDescent(normalizedActivityTypes) + || shouldExcludeDescentFromSettings ) { return statsAccu; } } + if (gradeAdjustedSpeedSet.has(statType) && !hasSpeedActivity) { + return statsAccu; + } + if (gradeAdjustedPaceSet.has(statType) && !hasPaceActivity) { + return statsAccu; + } if (statType === DataSpeedAvg.type) { - const speedMetrics = activityTypes.reduce((speedMetricsAccu: string[], activityType: ActivityTypes) => { - const metrics = ActivityTypesHelper.averageSpeedDerivedDataTypesToUseForActivityType(activityType); - return [...new Set([...speedMetricsAccu, ...(metrics || [])]).values()]; - }, [] as string[]); - return [...statsAccu, ...speedMetrics]; + const activityAwareGradeAdjustedTypes: string[] = [ + ...(hasSpeedActivity ? EVENT_SUMMARY_GRADE_ADJUSTED_SPEED_TYPES : []), + ...(hasPaceActivity ? EVENT_SUMMARY_GRADE_ADJUSTED_PACE_TYPES : []), + ]; + return [...new Set([...statsAccu, ...speedDerivedAverageTypes, ...activityAwareGradeAdjustedTypes]).values()]; } - return [...statsAccu, statType]; + return [...new Set([...statsAccu, statType]).values()]; }, [] as string[]); }; diff --git a/src/app/modules/event.module.ts b/src/app/modules/event.module.ts index 0fad8e8fd..2323acc9e 100644 --- a/src/app/modules/event.module.ts +++ b/src/app/modules/event.module.ts @@ -18,6 +18,8 @@ import { EventCardStatsGridComponent } from '../components/event/stats-grid/even import { EventCardChartComponent } from '../components/event/chart/event.card.chart.component'; import { ActivityToggleComponent } from '../components/event/activity-toggle/activity-toggle.component'; import { EventIntensityZonesComponent } from '../components/event/intensity-zones/event.intensity-zones.component'; +import { EventPowerCurveComponent } from '../components/event/power-curve/event.power-curve.component'; +import { EventPerformanceChartsComponent } from '../components/event/performance-charts/event.performance-charts.component'; import { LapTypeIconComponent } from '../components/lap-type-icon/lap-type-icon.component'; import { GoogleMapsModule } from '@angular/google-maps'; import { EventDetailsSummaryBottomSheetComponent } from '../components/event-summary/event-details-summary-bottom-sheet/event-details-summary-bottom-sheet.component'; @@ -28,6 +30,8 @@ import { JumpMarkerPopupComponent } from '../components/event/map/popups/jump-ma import { BenchmarkSelectionDialogComponent } from '../components/benchmark/benchmark-selection-dialog.component'; import { BenchmarkReportComponent } from '../components/benchmark/benchmark-report.component'; import { BenchmarkBottomSheetComponent } from '../components/benchmark/benchmark-bottom-sheet.component'; +import { EventSectionHeaderComponent } from '../components/event/section-header/event.section-header.component'; +import { DeviceNameEditDialogComponent } from '../components/event/activities-toggles/device-name-edit-dialog/device-name-edit-dialog.component'; @NgModule({ imports: [ @@ -59,11 +63,15 @@ import { BenchmarkBottomSheetComponent } from '../components/benchmark/benchmark ActivityToggleComponent, MapActionsComponent, EventIntensityZonesComponent, + EventPowerCurveComponent, + EventPerformanceChartsComponent, LapTypeIconComponent, JumpMarkerPopupComponent, BenchmarkSelectionDialogComponent, BenchmarkReportComponent, - BenchmarkBottomSheetComponent + BenchmarkBottomSheetComponent, + EventSectionHeaderComponent, + DeviceNameEditDialogComponent, ] }) diff --git a/src/app/modules/login.module.ts b/src/app/modules/login.module.ts index fab195964..ad8948fff 100644 --- a/src/app/modules/login.module.ts +++ b/src/app/modules/login.module.ts @@ -5,7 +5,6 @@ import { CommonModule } from '@angular/common'; import { LoginRoutingModule } from '../login.routing.module'; import { LoginComponent } from '../components/login/login.component'; import { UserAgreementFormComponent } from '../components/user-forms/user-agreement.form.component'; -import { DeleteConfirmationComponent } from '../components/delete-confirmation/delete-confirmation.component'; import { AccountLinkingDialogComponent } from '../components/login/account-linking-dialog/account-linking-dialog.component'; import { ErrorDialogComponent } from '../components/login/error-dialog/error-dialog.component'; diff --git a/src/app/modules/shared.module.ts b/src/app/modules/shared.module.ts index 4784d5c1d..3a2984714 100644 --- a/src/app/modules/shared.module.ts +++ b/src/app/modules/shared.module.ts @@ -9,7 +9,7 @@ import { PrivacyIconComponent } from '../components/privacy-icon/privacy-icon.co import { EventActionsComponent } from '../components/event-actions/event.actions.component'; import { EventFormComponent } from '../components/event-form/event.form.component'; import { ActivityFormComponent } from '../components/activity-form/activity.form.component'; -import { DeleteConfirmationComponent } from '../components/delete-confirmation/delete-confirmation.component'; +import { ConfirmationDialogComponent } from '../components/confirmation-dialog/confirmation-dialog.component'; import { DataTypeIconComponent } from '../components/data-type-icon/data-type-icon.component'; import { RouterModule } from '@angular/router'; @@ -23,6 +23,8 @@ import { ServiceSourceIconComponent } from '../components/event-summary/service- import { StatusInfoComponent } from '../components/shared/status-info/status-info.component'; import { BottomSheetHeaderComponent } from '../components/shared/bottom-sheet-header/bottom-sheet-header.component'; import { PeekPanelComponent } from '../components/shared/peek-panel/peek-panel.component'; +import { MaterialPillTabsComponent } from '../components/shared/material-pill-tabs/material-pill-tabs.component'; +import { MaterialPillTabDirective } from '../components/shared/material-pill-tabs/material-pill-tab.directive'; @NgModule({ imports: [ @@ -38,7 +40,7 @@ import { PeekPanelComponent } from '../components/shared/peek-panel/peek-panel.c EventActionsComponent, EventFormComponent, ActivityFormComponent, - DeleteConfirmationComponent, + ConfirmationDialogComponent, DataTypeIconComponent, EventSearchComponent, ActivityTypesMultiSelectComponent, @@ -50,6 +52,8 @@ import { PeekPanelComponent } from '../components/shared/peek-panel/peek-panel.c StatusInfoComponent, BottomSheetHeaderComponent, PeekPanelComponent, + MaterialPillTabsComponent, + MaterialPillTabDirective, ], providers: [], exports: [ @@ -63,7 +67,7 @@ import { PeekPanelComponent } from '../components/shared/peek-panel/peek-panel.c EventActionsComponent, EventFormComponent, ActivityFormComponent, - DeleteConfirmationComponent, + ConfirmationDialogComponent, DataTypeIconComponent, ReactiveFormsModule, FormsModule, @@ -75,6 +79,8 @@ import { PeekPanelComponent } from '../components/shared/peek-panel/peek-panel.c StatusInfoComponent, BottomSheetHeaderComponent, PeekPanelComponent, + MaterialPillTabsComponent, + MaterialPillTabDirective, ] }) diff --git a/src/app/resolvers/dashboard.resolver.spec.ts b/src/app/resolvers/dashboard.resolver.spec.ts index 4c4d2bfe1..24b2cb6ae 100644 --- a/src/app/resolvers/dashboard.resolver.spec.ts +++ b/src/app/resolvers/dashboard.resolver.spec.ts @@ -9,6 +9,7 @@ import { AppUserService } from '../services/app.user.service'; import { AppAuthService } from '../authentication/app.auth.service'; import { dashboardResolver, DashboardResolverData } from './dashboard.resolver'; import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { LoggerService } from '../services/logger.service'; describe('dashboardResolver', () => { const executeResolver: ResolveFn = (...resolverParameters) => @@ -19,6 +20,7 @@ describe('dashboardResolver', () => { let authServiceSpy: any; let routerSpy: any; let snackBarSpy: any; + let loggerSpy: any; const mockUser = new User('testUser') as AppUserInterface; mockUser.settings = { @@ -33,11 +35,12 @@ describe('dashboardResolver', () => { } as any; beforeEach(() => { - eventServiceSpy = { getEventsBy: vi.fn(), getEventsOnceBy: vi.fn() }; + eventServiceSpy = { getEventsBy: vi.fn(), getEventsOnceByWithMeta: vi.fn() }; userServiceSpy = { getUserByID: vi.fn() }; authServiceSpy = { user$: of(mockUser) }; routerSpy = { navigate: vi.fn() }; snackBarSpy = { open: vi.fn() }; + loggerSpy = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }; TestBed.configureTestingModule({ providers: [ @@ -45,7 +48,8 @@ describe('dashboardResolver', () => { { provide: AppUserService, useValue: userServiceSpy }, { provide: AppAuthService, useValue: authServiceSpy }, { provide: Router, useValue: routerSpy }, - { provide: MatSnackBar, useValue: snackBarSpy } + { provide: MatSnackBar, useValue: snackBarSpy }, + { provide: LoggerService, useValue: loggerSpy } ] }); }); @@ -55,7 +59,7 @@ describe('dashboardResolver', () => { }); it('should resolve with user and empty events when date range is all and no events returned', () => new Promise(done => { - eventServiceSpy.getEventsOnceBy.mockReturnValue(of([])); + eventServiceSpy.getEventsOnceByWithMeta.mockReturnValue(of({ events: [], source: 'cache' })); const route = new ActivatedRouteSnapshot(); vi.spyOn(route.paramMap, 'get').mockReturnValue(null); @@ -65,8 +69,19 @@ describe('dashboardResolver', () => { (executeResolver(route, state) as any).subscribe((result: DashboardResolverData) => { expect(result.user).toEqual(mockUser); expect(result.events).toEqual([]); + expect(result.eventsSource).toBe('cache'); expect(result.targetUser).toBeUndefined(); // or null depending on impl - expect(eventServiceSpy.getEventsOnceBy).toHaveBeenCalled(); + expect(eventServiceSpy.getEventsOnceByWithMeta).toHaveBeenCalledWith( + mockUser, + [], + 'startDate', + false, + 0, + { + preferCache: true, + warmServer: false + } + ); done(); }); })); @@ -74,7 +89,7 @@ describe('dashboardResolver', () => { it('should resolve with targetUser when userID is present', () => new Promise(done => { const mockTargetUser = new User('targetUser'); userServiceSpy.getUserByID.mockReturnValue(of(mockTargetUser)); - eventServiceSpy.getEventsOnceBy.mockReturnValue(of([])); + eventServiceSpy.getEventsOnceByWithMeta.mockReturnValue(of({ events: [], source: 'server' })); const route = new ActivatedRouteSnapshot(); vi.spyOn(route.paramMap, 'get').mockImplementation((key) => { @@ -87,6 +102,7 @@ describe('dashboardResolver', () => { (executeResolver(route, state) as any).subscribe((result: DashboardResolverData) => { expect(result.user).toEqual(mockUser); expect(result.targetUser).toEqual(mockTargetUser); + expect(result.eventsSource).toBe('server'); expect(userServiceSpy.getUserByID).toHaveBeenCalledWith('targetUser'); done(); }); @@ -97,7 +113,10 @@ describe('dashboardResolver', () => { mockUser.settings.dashboardSettings.activityTypes = []; const mergedEvent = { isMerge: true, getActivityTypesAsArray: () => [] } as any; const normalEvent = { isMerge: false, getActivityTypesAsArray: () => [] } as any; - eventServiceSpy.getEventsOnceBy.mockReturnValue(of([mergedEvent, normalEvent])); + eventServiceSpy.getEventsOnceByWithMeta.mockReturnValue(of({ + events: [mergedEvent, normalEvent], + source: 'cache' + })); const route = new ActivatedRouteSnapshot(); vi.spyOn(route.paramMap, 'get').mockReturnValue(null); @@ -106,13 +125,14 @@ describe('dashboardResolver', () => { (executeResolver(route, state) as any).subscribe((result: DashboardResolverData) => { expect(result.events).toEqual([normalEvent]); + expect(result.eventsSource).toBe('cache'); done(); }); })); it('should handle error when fetching targetUser and navigate', () => new Promise(done => { userServiceSpy.getUserByID.mockReturnValue(throwError(() => new Error('User not found'))); - eventServiceSpy.getEventsOnceBy.mockReturnValue(of([])); + eventServiceSpy.getEventsOnceByWithMeta.mockReturnValue(of({ events: [], source: 'cache' })); const route = new ActivatedRouteSnapshot(); vi.spyOn(route.paramMap, 'get').mockReturnValue('targetUser'); diff --git a/src/app/resolvers/dashboard.resolver.ts b/src/app/resolvers/dashboard.resolver.ts index 7815d3838..3866ed850 100644 --- a/src/app/resolvers/dashboard.resolver.ts +++ b/src/app/resolvers/dashboard.resolver.ts @@ -1,6 +1,6 @@ import { inject, Injector, runInInjectionContext } from '@angular/core'; import { ActivatedRouteSnapshot, ResolveFn, Router, RouterStateSnapshot } from '@angular/router'; -import { AppEventService } from '../services/app.event.service'; +import { AppEventService, type EventsOnceSource } from '../services/app.event.service'; import { AppUserService } from '../services/app.user.service'; import { EventInterface, ActivityTypes, DateRanges, DaysOfTheWeek } from '@sports-alliance/sports-lib'; import { AppUserInterface } from '../models/app-user.interface'; @@ -10,14 +10,18 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { AppAuthService } from '../authentication/app.auth.service'; import { WhereFilterOp } from 'firebase/firestore'; import { getDatesForDateRange } from '../helpers/date-range-helper'; +import { LoggerService } from '../services/logger.service'; export interface DashboardResolverData { events: EventInterface[]; user: AppUserInterface | null; targetUser?: AppUserInterface | null; hasMergedEvents?: boolean; + eventsSource?: EventsOnceSource; } +let dashboardResolverRunCounter = 0; + export const dashboardResolver: ResolveFn = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot @@ -27,10 +31,18 @@ export const dashboardResolver: ResolveFn = ( const authService = inject(AppAuthService); const router = inject(Router); const snackBar = inject(MatSnackBar); + const logger = inject(LoggerService); const injector = inject(Injector); + const runId = ++dashboardResolverRunCounter; + const resolverStart = performance.now(); // Get optional target user ID from route const targetUserID = route.paramMap.get('userID'); + logger.info('[perf] dashboard_resolver_start', { + runId, + url: state?.url || null, + targetUserID: targetUserID || null, + }); return authService.user$.pipe( take(1), @@ -38,19 +50,34 @@ export const dashboardResolver: ResolveFn = ( if (!user) { // If user is not authenticated, redirect to login and return empty data router.navigate(['login']); + logger.info('[perf] dashboard_resolver_unauthenticated', { + runId, + durationMs: Number((performance.now() - resolverStart).toFixed(2)), + }); return { events: [], user: null, targetUser: null, hasMergedEvents: false }; } let targetUser: AppUserInterface | undefined = undefined; if (targetUserID) { + const targetUserFetchStart = performance.now(); try { // We need to convert the Observable to a Promise or handle it in RxJS chain // Converting to promise inside async switchMap is okay for clarity // provided we handle concurrency correct, but better to use RxJS targetUser = await userService.getUserByID(targetUserID).pipe(take(1)).toPromise(); + logger.info('[perf] dashboard_resolver_target_user_fetch', { + runId, + durationMs: Number((performance.now() - targetUserFetchStart).toFixed(2)), + targetUserID, + }); } catch (e) { snackBar.open('Page not found'); router.navigate(['dashboard']); + logger.warn('[perf] dashboard_resolver_target_user_fetch_failed', { + runId, + durationMs: Number((performance.now() - targetUserFetchStart).toFixed(2)), + targetUserID, + }); return { events: [], user: user, targetUser: null, hasMergedEvents: false }; } } @@ -62,6 +89,10 @@ export const dashboardResolver: ResolveFn = ( // So we use `user.settings`. if (!user.settings?.dashboardSettings) { + logger.info('[perf] dashboard_resolver_missing_settings', { + runId, + durationMs: Number((performance.now() - resolverStart).toFixed(2)), + }); return { events: [], user: user, targetUser, hasMergedEvents: false }; } @@ -102,21 +133,62 @@ export const dashboardResolver: ResolveFn = ( const userContext = targetUser ? targetUser : user; const limit = 0; - const events = await firstValueFrom(eventService.getEventsOnceBy(userContext, where, 'startDate', false, limit)); - const rawEvents = events || []; + const eventsFetchStart = performance.now(); + const eventsResult = await firstValueFrom(eventService.getEventsOnceByWithMeta( + userContext, + where, + 'startDate', + false, + limit, + { + preferCache: true, + warmServer: false + } + )); + logger.info('[perf] dashboard_resolver_events_fetch', { + runId, + durationMs: Number((performance.now() - eventsFetchStart).toFixed(2)), + whereClauses: where.length, + events: eventsResult?.events?.length || 0, + source: eventsResult?.source || null, + userContextUID: userContext?.uid || null, + }); + const rawEvents = eventsResult?.events || []; const hasMergedEvents = rawEvents.some(event => event.isMerge); const filteredByMerge = includeMergedEvents ? rawEvents : rawEvents.filter(event => !event.isMerge); // Filter by Activity Types if (!user.settings.dashboardSettings.activityTypes || !user.settings.dashboardSettings.activityTypes.length) { - return { events: filteredByMerge || [], user: user, targetUser, hasMergedEvents }; + logger.info('[perf] dashboard_resolver_complete', { + runId, + durationMs: Number((performance.now() - resolverStart).toFixed(2)), + returnedEvents: filteredByMerge?.length || 0, + }); + return { + events: filteredByMerge || [], + user: user, + targetUser, + hasMergedEvents, + eventsSource: eventsResult?.source + }; } const filteredEvents = (filteredByMerge || []).filter(event => { return event.getActivityTypesAsArray().some(activityType => user.settings!.dashboardSettings!.activityTypes!.indexOf(ActivityTypes[activityType as unknown as keyof typeof ActivityTypes]) >= 0) }); - return { events: filteredEvents, user: user, targetUser, hasMergedEvents }; + logger.info('[perf] dashboard_resolver_complete', { + runId, + durationMs: Number((performance.now() - resolverStart).toFixed(2)), + returnedEvents: filteredEvents.length, + }); + return { + events: filteredEvents, + user: user, + targetUser, + hasMergedEvents, + eventsSource: eventsResult?.source + }; })), map((result) => { return result as DashboardResolverData; diff --git a/src/app/resolvers/event.resolver.spec.ts b/src/app/resolvers/event.resolver.spec.ts index e6e4dcc18..8d941e151 100644 --- a/src/app/resolvers/event.resolver.spec.ts +++ b/src/app/resolvers/event.resolver.spec.ts @@ -123,7 +123,7 @@ describe('eventResolver', () => { complete: () => { expect(routerSpy.navigate).toHaveBeenCalledWith(['/dashboard']); expect(snackBarSpy.open).toHaveBeenCalledWith( - 'Event data unavailable: Original file missing and legacy access denied.', + 'Event data unavailable: original source files are missing or invalid.', 'Close', { duration: 5000 } ); diff --git a/src/app/resolvers/event.resolver.ts b/src/app/resolvers/event.resolver.ts index d2e472dba..413994851 100644 --- a/src/app/resolvers/event.resolver.ts +++ b/src/app/resolvers/event.resolver.ts @@ -84,8 +84,12 @@ export const eventResolver: ResolveFn = ( catchError((error) => { logger.error('Error resolving event:', error); let message = 'Error loading event'; - if (error?.message?.includes('Missing or insufficient permissions') || error?.code === 'permission-denied') { - message = 'Event data unavailable: Original file missing and legacy access denied.'; + if ( + error?.message?.includes('Missing or insufficient permissions') + || error?.code === 'permission-denied' + || error?.message?.includes('original source file') + ) { + message = 'Event data unavailable: original source files are missing or invalid.'; } snackBar.open(message, 'Close', { duration: 5000 }); router.navigate(['/dashboard']); diff --git a/src/app/services/app.benchmark-flow.service.spec.ts b/src/app/services/app.benchmark-flow.service.spec.ts index 6a2d62fba..ad861fd87 100644 --- a/src/app/services/app.benchmark-flow.service.spec.ts +++ b/src/app/services/app.benchmark-flow.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { of } from 'rxjs'; +import { Overlay } from '@angular/cdk/overlay'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -10,10 +11,12 @@ import { AppBenchmarkFlowService } from './app.benchmark-flow.service'; import { AppBenchmarkService } from './app.benchmark.service'; import { AppEventService } from './app.event.service'; import { LoggerService } from './logger.service'; +import { AppAnalyticsService } from './app.analytics.service'; describe('AppBenchmarkFlowService', () => { let service: AppBenchmarkFlowService; let bottomSheet: { open: ReturnType }; + let overlay: { scrollStrategies: { noop: ReturnType } }; let dialog: { open: ReturnType }; let snackBar: { open: ReturnType }; let benchmarkService: { generateBenchmark: ReturnType }; @@ -22,6 +25,7 @@ describe('AppBenchmarkFlowService', () => { getEventActivitiesAndAllStreams: ReturnType; }; let logger: { error: ReturnType }; + let analyticsService: { logEvent: ReturnType }; const activityA = { getID: () => 'a1' } as ActivityInterface; const activityB = { getID: () => 'b1' } as ActivityInterface; @@ -46,6 +50,7 @@ describe('AppBenchmarkFlowService', () => { beforeEach(() => { bottomSheet = { open: vi.fn().mockReturnValue({ afterDismissed: () => of(undefined) }) }; + overlay = { scrollStrategies: { noop: vi.fn().mockReturnValue({}) } }; dialog = { open: vi.fn().mockReturnValue({ afterClosed: () => of(undefined), componentInstance: { setActivities: vi.fn() } }) }; snackBar = { open: vi.fn() }; benchmarkService = { generateBenchmark: vi.fn() }; @@ -54,16 +59,19 @@ describe('AppBenchmarkFlowService', () => { getEventActivitiesAndAllStreams: vi.fn(), }; logger = { error: vi.fn() }; + analyticsService = { logEvent: vi.fn() }; TestBed.configureTestingModule({ providers: [ AppBenchmarkFlowService, { provide: MatBottomSheet, useValue: bottomSheet }, + { provide: Overlay, useValue: overlay }, { provide: MatDialog, useValue: dialog }, { provide: MatSnackBar, useValue: snackBar }, { provide: AppBenchmarkService, useValue: benchmarkService }, { provide: AppEventService, useValue: eventService }, { provide: LoggerService, useValue: logger }, + { provide: AppAnalyticsService, useValue: analyticsService }, ] }); @@ -88,6 +96,19 @@ describe('AppBenchmarkFlowService', () => { expect(bottomSheet.open).toHaveBeenCalledTimes(1); expect(dialog.open).toHaveBeenCalledTimes(1); + expect(analyticsService.logEvent).not.toHaveBeenCalled(); + }); + + it('passes user brandText to benchmark bottom sheet data', () => { + const event = createEvent(); + const result = createResult(); + const user = { uid: 'user-1', brandText: 'My Brand' } as User; + + service.openBenchmarkReport({ event, result, user }); + + const openCallArgs = bottomSheet.open.mock.calls[0]; + expect(openCallArgs).toBeTruthy(); + expect(openCallArgs[1]?.data?.brandText).toBe('My Brand'); }); it('opens selection dialog and runs benchmark when two activities returned', async () => { @@ -107,6 +128,7 @@ describe('AppBenchmarkFlowService', () => { test: activityB, options })); + expect(analyticsService.logEvent).not.toHaveBeenCalled(); }); it('loads activities when missing and user provided', async () => { @@ -127,6 +149,7 @@ describe('AppBenchmarkFlowService', () => { expect(eventService.getEventActivitiesAndAllStreams).toHaveBeenCalledWith(user, emptyEvent.getID()); expect(dialog.open).toHaveBeenCalledTimes(1); + expect(analyticsService.logEvent).not.toHaveBeenCalled(); }); it('generates, persists, and reopens report', async () => { @@ -160,6 +183,8 @@ describe('AppBenchmarkFlowService', () => { ); expect(onResult).toHaveBeenCalledWith(result); expect(bottomSheet.open).toHaveBeenCalled(); + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_start'); + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_success'); }); it('skips persistence when no user is provided', async () => { @@ -177,5 +202,24 @@ describe('AppBenchmarkFlowService', () => { }); expect(eventService.updateEventProperties).not.toHaveBeenCalled(); + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_start'); + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_success'); + }); + + it('logs failure analytics when benchmark generation fails', async () => { + const event = createEvent(); + const options: BenchmarkOptions = { autoAlignTime: true }; + + benchmarkService.generateBenchmark.mockRejectedValueOnce(new Error('boom')); + + await service.generateAndOpenReport({ + event, + ref: activityA, + test: activityB, + options + }); + + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_start'); + expect(analyticsService.logEvent).toHaveBeenCalledWith('benchmark_generate_failure'); }); }); diff --git a/src/app/services/app.benchmark-flow.service.ts b/src/app/services/app.benchmark-flow.service.ts index ba7112a8b..385fb2d0c 100644 --- a/src/app/services/app.benchmark-flow.service.ts +++ b/src/app/services/app.benchmark-flow.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -7,6 +8,7 @@ import { AppEventInterface, BenchmarkOptions, BenchmarkResult, getBenchmarkPairK import { AppBenchmarkService } from './app.benchmark.service'; import { AppEventService } from './app.event.service'; import { LoggerService } from './logger.service'; +import { AppAnalyticsService } from './app.analytics.service'; import { BenchmarkBottomSheetComponent } from '../components/benchmark/benchmark-bottom-sheet.component'; import { BenchmarkSelectionDialogComponent } from '../components/benchmark/benchmark-selection-dialog.component'; import { firstValueFrom } from 'rxjs'; @@ -27,11 +29,13 @@ interface BenchmarkFlowConfig { export class AppBenchmarkFlowService { constructor( private bottomSheet: MatBottomSheet, + private overlay: Overlay, private dialog: MatDialog, private snackBar: MatSnackBar, private benchmarkService: AppBenchmarkService, private eventService: AppEventService, - private logger: LoggerService + private logger: LoggerService, + private analyticsService: AppAnalyticsService ) { } openBenchmarkReport(config: BenchmarkFlowConfig): void { @@ -42,9 +46,11 @@ export class AppBenchmarkFlowService { result: config.result, event: config.event, unitSettings: config.user?.settings?.unitSettings ?? AppUserUtilities.getDefaultUserUnitSettings(), - summariesSettings: config.user?.settings?.summariesSettings + summariesSettings: config.user?.settings?.summariesSettings, + brandText: (config.user as any)?.brandText ?? null, }, - autoFocus: 'dialog' + autoFocus: 'dialog', + scrollStrategy: this.overlay.scrollStrategies.noop() }); sheetRef.afterDismissed().subscribe((res: { rerun?: boolean } | undefined) => { @@ -88,6 +94,7 @@ export class AppBenchmarkFlowService { test: result.activities[1], options: result.options }); + return; } }); @@ -112,6 +119,7 @@ export class AppBenchmarkFlowService { this.snackBar.open('Generating Benchmark...', undefined, { duration: 2000 }); try { + this.analyticsService.logEvent('benchmark_generate_start'); const benchmarkResult = await this.benchmarkService.generateBenchmark(config.ref, config.test, config.options); const key = getBenchmarkPairKey(config.ref.getID()!, config.test.getID()!); @@ -144,10 +152,12 @@ export class AppBenchmarkFlowService { }); } + this.analyticsService.logEvent('benchmark_generate_success'); config.onResult?.(benchmarkResult); this.openBenchmarkReport({ ...config, result: benchmarkResult }); this.snackBar.open('Benchmark Generated & Saved!', undefined, { duration: 2000 }); } catch (error) { + this.analyticsService.logEvent('benchmark_generate_failure'); this.snackBar.open('Benchmark failed: ' + error, 'Close'); this.logger.error('Benchmark flow failed', error); } diff --git a/src/app/services/app.event-reprocess.service.spec.ts b/src/app/services/app.event-reprocess.service.spec.ts new file mode 100644 index 000000000..099d14d12 --- /dev/null +++ b/src/app/services/app.event-reprocess.service.spec.ts @@ -0,0 +1,383 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ActivityUtilities, EventUtilities } from '@sports-alliance/sports-lib'; +import { AppEventReprocessService, ReprocessError, ReprocessProgress } from './app.event-reprocess.service'; +import { AppEventService } from './app.event.service'; +import { AppOriginalFileHydrationService } from './app.original-file-hydration.service'; + +describe('AppEventReprocessService', () => { + let service: AppEventReprocessService; + let eventServiceMock: any; + let hydrationServiceMock: any; + + beforeEach(() => { + eventServiceMock = { + attachStreamsToEventWithActivities: vi.fn(), + writeAllEventData: vi.fn().mockResolvedValue(undefined), + }; + hydrationServiceMock = { + parseEventFromOriginalFiles: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + AppEventReprocessService, + { provide: AppEventService, useValue: eventServiceMock }, + { provide: AppOriginalFileHydrationService, useValue: hydrationServiceMock }, + ] + }); + + service = TestBed.inject(AppEventReprocessService); + vi.restoreAllMocks(); + }); + + it('should regenerate event stats with preserved non-regenerated stat types', async () => { + const oldOnlyStat = { getType: () => 'old-only' }; + const staleRegeneratedStat = { getType: () => 'to-regenerate', stale: true }; + const generatedStat = { getType: () => 'to-regenerate', stale: false }; + const statsMap = new Map([ + ['old-only', oldOnlyStat], + ['to-regenerate', staleRegeneratedStat], + ]); + const activity = { + getStats: vi.fn().mockImplementation(() => statsMap), + clearStats: vi.fn().mockImplementation(() => statsMap.clear()), + getStat: vi.fn().mockImplementation((type: string) => statsMap.get(type)), + addStat: vi.fn().mockImplementation((stat: any) => statsMap.set(stat.getType(), stat)), + }; + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([activity]), + isMerge: false, + } as any; + + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + vi.spyOn(ActivityUtilities, 'generateMissingStreamsAndStatsForActivity').mockImplementation((target: any) => { + target.addStat(generatedStat); + }); + const regenerateSpy = vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await service.regenerateEventStatistics({ uid: 'u1' } as any, event); + + expect(eventServiceMock.attachStreamsToEventWithActivities).toHaveBeenCalledWith( + { uid: 'u1' }, + event, + undefined, + true, + false, + 'replace_activities', + ); + expect(ActivityUtilities.generateMissingStreamsAndStatsForActivity).toHaveBeenCalledWith(activity as any); + expect(statsMap.get('old-only')).toBe(oldOnlyStat); + expect(statsMap.get('to-regenerate')).toBe(generatedStat); + expect(regenerateSpy).toHaveBeenCalledWith(event); + expect(eventServiceMock.writeAllEventData).toHaveBeenCalledWith({ uid: 'u1' }, event); + }); + + it('should throw NO_ORIGINAL_FILES when regenerateEventStatistics has no source metadata', async () => { + const event = { + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(service.regenerateEventStatistics({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'NO_ORIGINAL_FILES', + }); + }); + + it('should throw PARSE_FAILED when regenerateEventStatistics rehydrate fails', async () => { + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([]), + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(throwError(() => new Error('parse failed'))); + + await expect(service.regenerateEventStatistics({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'PARSE_FAILED', + }); + }); + + it('should regenerate activity statistics using simplified behavior', async () => { + const activity = { getID: () => 'a-1' } as any; + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([activity]), + isMerge: true, + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + const regenerateSpy = vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + const activityGenerateSpy = vi.spyOn(ActivityUtilities, 'generateMissingStreamsAndStatsForActivity').mockImplementation(() => { }); + + const result = await service.regenerateActivityStatistics({ uid: 'u1' } as any, event, 'a-1'); + + expect(result.updatedActivityId).toBe('a-1'); + expect(activityGenerateSpy).not.toHaveBeenCalled(); + expect(regenerateSpy).toHaveBeenCalledWith(event); + expect(eventServiceMock.writeAllEventData).toHaveBeenCalledWith({ uid: 'u1' }, event); + }); + + it('should throw ACTIVITY_NOT_FOUND_AFTER_REHYDRATE when activity is missing', async () => { + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([]), + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + + await expect(service.regenerateActivityStatistics({ uid: 'u1' } as any, event, 'missing-id')).rejects.toMatchObject({ + code: 'ACTIVITY_NOT_FOUND_AFTER_REHYDRATE', + }); + }); + + it('should throw NO_ORIGINAL_FILES when regenerateActivityStatistics has no source metadata', async () => { + const event = { + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(service.regenerateActivityStatistics({ uid: 'u1' } as any, event, 'a-1')).rejects.toMatchObject({ + code: 'NO_ORIGINAL_FILES', + }); + }); + + it('should pass skipEnrichment option into attachStreamsToEventWithActivities', async () => { + const activity = { getID: () => 'a-1' } as any; + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([activity]), + isMerge: false, + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await service.regenerateActivityStatistics({ uid: 'u1' } as any, event, 'a-1', { skipEnrichment: true }); + + expect(eventServiceMock.attachStreamsToEventWithActivities).toHaveBeenCalledWith( + { uid: 'u1' }, + event, + undefined, + true, + true, + 'replace_activities', + ); + }); + + it('should reimport multi-file events and preserve original isMerge flag', async () => { + const parsedActivity1 = { getID: () => 'a-1' }; + const parsedActivity2 = { getID: () => 'a-2' }; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([parsedActivity1, parsedActivity2]), + } as any; + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }, { path: 'f2.fit' }], + originalFile: { path: 'f1.fit' }, + isMerge: false, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 2, + failedFiles: [], + }); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + const result = await service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event); + + expect(hydrationServiceMock.parseEventFromOriginalFiles).toHaveBeenCalledWith( + event, + expect.objectContaining({ strictAllFilesRequired: true }), + ); + expect(parsedEvent.setID).toHaveBeenCalledWith('event-1'); + expect((parsedEvent as any).isMerge).toBe(false); + expect((event as any).isMerge).toBe(false); + expect(event.clearActivities).toHaveBeenCalled(); + expect(event.addActivities).toHaveBeenCalledWith([parsedActivity1, parsedActivity2]); + expect(result.preservedIsMerge).toBe(false); + expect(result.wasMultiFileSource).toBe(true); + }); + + it('should preserve true isMerge flag during reimport', async () => { + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }, { path: 'f2.fit' }], + originalFile: { path: 'f1.fit' }, + isMerge: true, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 2, + failedFiles: [], + }); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + const result = await service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event); + + expect((parsedEvent as any).isMerge).toBe(true); + expect((event as any).isMerge).toBe(true); + expect(result.preservedIsMerge).toBe(true); + }); + + it('should fail whole reimport when any source file fails', async () => { + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }, { path: 'f2.fit' }], + isMerge: true, + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: null, + parsedEvents: [], + sourceFilesCount: 2, + failedFiles: [{ path: 'f2.fit', reason: 'parse error' }], + }); + + await expect(service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'MULTI_FILE_INCOMPLETE', + }); + expect(eventServiceMock.writeAllEventData).not.toHaveBeenCalled(); + expect(event.clearActivities).not.toHaveBeenCalled(); + }); + + it('should throw PARSE_FAILED when reimport has no final event and no failed files', async () => { + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }], + isMerge: false, + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: null, + parsedEvents: [], + sourceFilesCount: 1, + failedFiles: [], + }); + + await expect(service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'PARSE_FAILED', + }); + }); + + it('should throw NO_ORIGINAL_FILES when reimport has no source metadata', async () => { + const event = { + getID: () => 'event-1', + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'NO_ORIGINAL_FILES', + }); + }); + + it('should throw PERSIST_FAILED when reimport save fails', async () => { + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }], + originalFile: { path: 'f1.fit' }, + isMerge: false, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [], + }); + eventServiceMock.writeAllEventData.mockRejectedValueOnce(new Error('write failed')); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await expect(service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'PERSIST_FAILED', + }); + }); + + it('should emit ordered progress phases', async () => { + const phases: ReprocessProgress['phase'][] = []; + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([]), + isMerge: false, + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await service.regenerateEventStatistics({ uid: 'u1' } as any, event, { + onProgress: (progress) => phases.push(progress.phase), + }); + + expect(phases[0]).toBe('validating'); + expect(phases).toContain('parsing'); + expect(phases).toContain('persisting'); + expect(phases[phases.length - 1]).toBe('done'); + }); + + it('should emit reimport progress phases including merging', async () => { + const phases: ReprocessProgress['phase'][] = []; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + const event = { + getID: () => 'event-1', + originalFiles: [{ path: 'f1.fit' }], + originalFile: { path: 'f1.fit' }, + isMerge: false, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + hydrationServiceMock.parseEventFromOriginalFiles.mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [], + }); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await service.reimportEventFromOriginalFiles({ uid: 'u1' } as any, event, { + onProgress: (progress) => phases.push(progress.phase), + skipEnrichment: true, + }); + + expect(hydrationServiceMock.parseEventFromOriginalFiles).toHaveBeenCalledWith( + event, + expect.objectContaining({ skipEnrichment: true }), + ); + expect(phases).toEqual(expect.arrayContaining(['validating', 'downloading', 'parsing', 'merging', 'persisting', 'done'])); + }); + + it('should surface persist failures as typed errors', async () => { + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: vi.fn().mockReturnValue([]), + isMerge: false, + } as any; + eventServiceMock.attachStreamsToEventWithActivities.mockReturnValue(of(event)); + eventServiceMock.writeAllEventData.mockRejectedValueOnce(new Error('write failed')); + vi.spyOn(EventUtilities, 'reGenerateStatsForEvent').mockImplementation(() => { }); + + await expect(service.regenerateEventStatistics({ uid: 'u1' } as any, event)).rejects.toMatchObject({ + code: 'PERSIST_FAILED', + }); + }); +}); diff --git a/src/app/services/app.event-reprocess.service.ts b/src/app/services/app.event-reprocess.service.ts new file mode 100644 index 000000000..6c46b8ccd --- /dev/null +++ b/src/app/services/app.event-reprocess.service.ts @@ -0,0 +1,250 @@ +import { Injectable, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ActivityUtilities, EventUtilities, User } from '@sports-alliance/sports-lib'; +import { AppEventInterface } from '../../../functions/src/shared/app-event.interface'; +import { AppEventService } from './app.event.service'; +import { AppOriginalFileHydrationService } from './app.original-file-hydration.service'; + +export type ReprocessPhase = + | 'validating' + | 'downloading' + | 'parsing' + | 'merging' + | 'regenerating_stats' + | 'persisting' + | 'done'; + +export interface ReprocessProgress { + phase: ReprocessPhase; + progress: number; + details?: string; +} + +export interface ReprocessOptions { + onProgress?: (progress: ReprocessProgress) => void; + skipEnrichment?: boolean; +} + +export interface ReprocessResult { + event: AppEventInterface; + updatedActivityId?: string; + sourceFilesCount: number; + wasMultiFileSource: boolean; + preservedIsMerge: boolean; +} + +export type ReprocessErrorCode = + | 'NO_ORIGINAL_FILES' + | 'PARSE_FAILED' + | 'MULTI_FILE_INCOMPLETE' + | 'ACTIVITY_NOT_FOUND_AFTER_REHYDRATE' + | 'PERSIST_FAILED'; + +export class ReprocessError extends Error { + constructor( + public code: ReprocessErrorCode, + message: string, + public cause?: unknown, + ) { + super(message); + this.name = 'ReprocessError'; + } +} + +@Injectable({ + providedIn: 'root' +}) +export class AppEventReprocessService { + private eventService = inject(AppEventService); + private originalFileHydrationService = inject(AppOriginalFileHydrationService); + + public async regenerateEventStatistics( + user: User, + event: AppEventInterface, + options?: ReprocessOptions, + ): Promise { + this.notifyProgress(options, { phase: 'validating', progress: 5, details: 'Validating source files' }); + const sourceFilesCount = this.getSourceFileCount(event); + if (sourceFilesCount === 0) { + throw new ReprocessError('NO_ORIGINAL_FILES', 'No original source file metadata found for this event.'); + } + + try { + this.notifyProgress(options, { phase: 'downloading', progress: 20, details: 'Loading source files' }); + this.notifyProgress(options, { phase: 'parsing', progress: 40, details: 'Parsing activities' }); + await firstValueFrom( + this.eventService.attachStreamsToEventWithActivities( + user, + event, + undefined, + true, + options?.skipEnrichment === true, + 'replace_activities', + ), + ); + } catch (e) { + throw new ReprocessError('PARSE_FAILED', 'Could not parse original source file(s).', e); + } + + this.notifyProgress(options, { phase: 'regenerating_stats', progress: 65, details: 'Re-generating activity statistics' }); + event.getActivities().forEach(activity => { + const previousStats = new Map(activity.getStats()); + activity.clearStats(); + ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); + previousStats.forEach((stat, type) => { + if (!activity.getStat(type)) { + activity.addStat(stat); + } + }); + }); + + EventUtilities.reGenerateStatsForEvent(event); + + this.notifyProgress(options, { phase: 'persisting', progress: 90, details: 'Saving updated event' }); + try { + await this.eventService.writeAllEventData(user, event); + } catch (e) { + throw new ReprocessError('PERSIST_FAILED', 'Could not persist re-generated event data.', e); + } + + this.notifyProgress(options, { phase: 'done', progress: 100, details: 'Done' }); + return { + event, + sourceFilesCount, + wasMultiFileSource: sourceFilesCount > 1, + preservedIsMerge: !!(event as any).isMerge, + }; + } + + public async regenerateActivityStatistics( + user: User, + event: AppEventInterface, + activityId: string, + options?: ReprocessOptions, + ): Promise { + this.notifyProgress(options, { phase: 'validating', progress: 5, details: 'Validating source files' }); + const sourceFilesCount = this.getSourceFileCount(event); + if (sourceFilesCount === 0) { + throw new ReprocessError('NO_ORIGINAL_FILES', 'No original source file metadata found for this event.'); + } + + try { + this.notifyProgress(options, { phase: 'downloading', progress: 20, details: 'Loading source files' }); + this.notifyProgress(options, { phase: 'parsing', progress: 40, details: 'Parsing activities' }); + await firstValueFrom( + this.eventService.attachStreamsToEventWithActivities( + user, + event, + undefined, + true, + options?.skipEnrichment === true, + 'replace_activities', + ), + ); + } catch (e) { + throw new ReprocessError('PARSE_FAILED', 'Could not parse original source file(s).', e); + } + + const updatedActivity = event.getActivities().find(activity => activity.getID() === activityId); + if (!updatedActivity) { + throw new ReprocessError( + 'ACTIVITY_NOT_FOUND_AFTER_REHYDRATE', + `Activity ${activityId} was not found after rehydrating source files.`, + ); + } + + this.notifyProgress(options, { phase: 'regenerating_stats', progress: 70, details: 'Re-generating event statistics' }); + EventUtilities.reGenerateStatsForEvent(event); + + this.notifyProgress(options, { phase: 'persisting', progress: 90, details: 'Saving updated event' }); + try { + await this.eventService.writeAllEventData(user, event); + } catch (e) { + throw new ReprocessError('PERSIST_FAILED', 'Could not persist re-generated event data.', e); + } + + this.notifyProgress(options, { phase: 'done', progress: 100, details: 'Done' }); + return { + event, + updatedActivityId: updatedActivity.getID(), + sourceFilesCount, + wasMultiFileSource: sourceFilesCount > 1, + preservedIsMerge: !!(event as any).isMerge, + }; + } + + public async reimportEventFromOriginalFiles( + user: User, + event: AppEventInterface, + options?: ReprocessOptions, + ): Promise { + this.notifyProgress(options, { phase: 'validating', progress: 5, details: 'Validating source files' }); + const sourceFilesCount = this.getSourceFileCount(event); + if (sourceFilesCount === 0) { + throw new ReprocessError('NO_ORIGINAL_FILES', 'No original source file metadata found for this event.'); + } + + const originalIsMerge = !!(event as any).isMerge; + const eventAny = event as any; + const originalFiles = eventAny.originalFiles; + const originalFile = eventAny.originalFile; + this.notifyProgress(options, { phase: 'downloading', progress: 20, details: 'Downloading source files' }); + this.notifyProgress(options, { phase: 'parsing', progress: 40, details: 'Parsing source files' }); + const parseResult = await this.originalFileHydrationService.parseEventFromOriginalFiles(event, { + skipEnrichment: options?.skipEnrichment === true, + strictAllFilesRequired: true, + preserveActivityIdsFromEvent: true, + mergeMultipleFiles: true, + }); + + if (parseResult.failedFiles.length > 0) { + const details = parseResult.failedFiles.map(file => `${file.path}: ${file.reason}`).join('; '); + throw new ReprocessError('MULTI_FILE_INCOMPLETE', `Reimport aborted because one or more source files failed to parse. ${details}`); + } + + if (!parseResult.finalEvent) { + throw new ReprocessError('PARSE_FAILED', 'Could not parse original source file(s).'); + } + + this.notifyProgress(options, { phase: 'merging', progress: 60, details: 'Preparing reimported event data' }); + const reimportedEvent = parseResult.finalEvent as AppEventInterface; + reimportedEvent.setID(event.getID()); + (reimportedEvent as any).isMerge = originalIsMerge; + (reimportedEvent as any).originalFiles = originalFiles; + (reimportedEvent as any).originalFile = originalFile; + + this.notifyProgress(options, { phase: 'regenerating_stats', progress: 75, details: 'Re-building event statistics' }); + EventUtilities.reGenerateStatsForEvent(reimportedEvent); + + this.notifyProgress(options, { phase: 'persisting', progress: 90, details: 'Saving reimported event' }); + try { + await this.eventService.writeAllEventData(user, reimportedEvent); + } catch (e) { + throw new ReprocessError('PERSIST_FAILED', 'Could not persist reimported event data.', e); + } + + event.clearActivities(); + event.addActivities(reimportedEvent.getActivities()); + (event as any).isMerge = originalIsMerge; + EventUtilities.reGenerateStatsForEvent(event); + + this.notifyProgress(options, { phase: 'done', progress: 100, details: 'Done' }); + return { + event, + sourceFilesCount: parseResult.sourceFilesCount, + wasMultiFileSource: parseResult.sourceFilesCount > 1, + preservedIsMerge: originalIsMerge, + }; + } + + private notifyProgress(options: ReprocessOptions | undefined, progress: ReprocessProgress): void { + options?.onProgress?.(progress); + } + + private getSourceFileCount(event: AppEventInterface): number { + if (event.originalFiles && event.originalFiles.length > 0) { + return event.originalFiles.length; + } + return event.originalFile?.path ? 1 : 0; + } +} diff --git a/src/app/services/app.event.service.spec.ts b/src/app/services/app.event.service.spec.ts index f479ba3d0..32b8c7454 100644 --- a/src/app/services/app.event.service.spec.ts +++ b/src/app/services/app.event.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { AppEventService } from './app.event.service'; -import { Firestore, doc, docData, collection, collectionData, deleteDoc, setDoc } from '@angular/fire/firestore'; +import { Firestore, doc, docData, collection, collectionData, deleteDoc, setDoc, writeBatch } from '@angular/fire/firestore'; import { Storage } from '@angular/fire/storage'; import { Auth } from '@angular/fire/auth'; import { AppAnalyticsService } from './app.analytics.service'; @@ -10,7 +10,7 @@ import { AppFileService } from './app.file.service'; import { BrowserCompatibilityService } from './browser.compatibility.service'; import { AppEventUtilities } from '../utils/app.event.utilities'; import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; -import { of, firstValueFrom } from 'rxjs'; +import { of, firstValueFrom, Subject } from 'rxjs'; import { AppCacheService } from './app.cache.service'; import { getMetadata } from '@angular/fire/storage'; import { webcrypto } from 'node:crypto'; @@ -34,6 +34,8 @@ const mocks = vi.hoisted(() => { sanitize: vi.fn(), getCountFromServer: vi.fn(), getBytes: vi.fn(), + batchSet: vi.fn(), + batchCommit: vi.fn(), }; }); @@ -50,6 +52,10 @@ vi.mock('@angular/fire/firestore', async (importOriginal) => { where: vi.fn(), deleteDoc: vi.fn(), setDoc: vi.fn(), + writeBatch: vi.fn(() => ({ + set: mocks.batchSet, + commit: mocks.batchCommit, + })), getCountFromServer: mocks.getCountFromServer, runInInjectionContext: vi.fn((injector, fn) => fn()), }; @@ -163,6 +169,7 @@ describe('AppEventService', () => { toJSON: vi.fn().mockReturnValue({}) }); mocks.getCountFromServer.mockResolvedValue({ data: () => ({ count: 0 }) }); + mocks.batchCommit.mockResolvedValue(undefined); mocks.writeAllEventData.mockResolvedValue(true); // Polyfills @@ -233,6 +240,96 @@ describe('AppEventService', () => { expect(result!.getID()).toBe('event1'); }); + it('should emit live event details updates when event metadata changes', async () => { + const userId = 'user1'; + const eventId = 'event1'; + const user = { uid: userId } as any; + + const firstSnapshot = { id: eventId, name: 'Initial Name' }; + const secondSnapshot = { id: eventId, name: 'Updated Name' }; + const mockActivityData = { id: 'act1', eventID: eventId, type: 'Run' }; + const eventSnapshots$ = new Subject(); + const emissions: any[] = []; + + (doc as Mock).mockReturnValue({}); + (collection as Mock).mockReturnValue({}); + (docData as Mock).mockReturnValue(eventSnapshots$.asObservable()); + (collectionData as Mock).mockReturnValue(of([mockActivityData])); + mocks.sanitize.mockImplementation((json: any) => ({ sanitizedJson: json, unknownTypes: [], issues: [] })); + mocks.getEventFromJSON.mockImplementation((json: any) => { + const event: any = { + name: json.name, + activities: [], + setID: vi.fn().mockReturnThis(), + clearActivities: vi.fn(() => { + event.activities = []; + }), + addActivities: vi.fn((activities: any[]) => { + event.activities = activities; + }), + getActivities: vi.fn(() => event.activities), + getID: vi.fn(() => eventId), + }; + return event; + }); + + const subscription = service.getEventDetailsLive(user, eventId).subscribe((event) => { + emissions.push(event); + }); + eventSnapshots$.next(firstSnapshot); + eventSnapshots$.next(secondSnapshot); + await Promise.resolve(); + subscription.unsubscribe(); + + expect(emissions).toHaveLength(2); + expect((emissions[0] as any).name).toBe('Initial Name'); + expect((emissions[1] as any).name).toBe('Updated Name'); + }); + + it('should suppress duplicate live event-detail emissions for unchanged snapshots', async () => { + const userId = 'user1'; + const eventId = 'event1'; + const user = { uid: userId } as any; + + const snapshot = { id: eventId, name: 'Same Name' }; + const mockActivityData = { id: 'act1', eventID: eventId, type: 'Run' }; + const eventSnapshots$ = new Subject(); + const emissions: any[] = []; + + (doc as Mock).mockReturnValue({}); + (collection as Mock).mockReturnValue({}); + (docData as Mock).mockReturnValue(eventSnapshots$.asObservable()); + (collectionData as Mock).mockReturnValue(of([mockActivityData])); + mocks.sanitize.mockImplementation((json: any) => ({ sanitizedJson: json, unknownTypes: [], issues: [] })); + mocks.getEventFromJSON.mockImplementation((json: any) => { + const event: any = { + name: json.name, + activities: [], + setID: vi.fn().mockReturnThis(), + clearActivities: vi.fn(() => { + event.activities = []; + }), + addActivities: vi.fn((activities: any[]) => { + event.activities = activities; + }), + getActivities: vi.fn(() => event.activities), + getID: vi.fn(() => eventId), + }; + return event; + }); + + const subscription = service.getEventDetailsLive(user, eventId).subscribe((event) => { + emissions.push(event); + }); + eventSnapshots$.next(snapshot); + eventSnapshots$.next(snapshot); + await Promise.resolve(); + subscription.unsubscribe(); + + expect(emissions).toHaveLength(1); + expect((emissions[0] as any).name).toBe('Same Name'); + }); + it('should warn and send to Sentry when sanitizer reports malformed activity issues', async () => { const userId = 'user1'; const eventId = 'event1'; @@ -447,6 +544,105 @@ describe('AppEventService', () => { expect(result).toBe(true); }); + it('should strip streams before writing activity in setActivity', async () => { + const user = { uid: 'user1' } as any; + const event = { getID: () => 'event1', startDate: new Date('2026-01-01T00:00:00.000Z') } as any; + const activity = { + getID: () => 'activity1', + toJSON: () => ({ + stats: {}, + streams: [ + { type: 'Pace', values: [1, 2, 3] } + ] + }) + } as any; + + (doc as Mock).mockReturnValue({}); + (setDoc as Mock).mockResolvedValue(undefined); + + await service.setActivity(user, event, activity); + + expect(setDoc).toHaveBeenCalledTimes(1); + const writtenPayload = (setDoc as Mock).mock.calls[0][1]; + expect(writtenPayload).not.toHaveProperty('streams'); + expect(writtenPayload.eventID).toBe('event1'); + expect(writtenPayload.userID).toBe('user1'); + expect(writtenPayload.eventStartDate).toEqual(event.startDate); + }); + + it('should preserve original file metadata and merge on setEvent', async () => { + const user = { uid: 'user1' } as any; + const event = { + getID: () => 'event1', + toJSON: () => ({ name: 'My Event' }), + originalFiles: [{ path: 'users/user1/events/event1/original.fit', startDate: new Date('2026-01-01T00:00:00.000Z') }], + originalFile: { path: 'users/user1/events/event1/original.fit', startDate: new Date('2026-01-01T00:00:00.000Z') }, + } as any; + + (doc as Mock).mockReturnValue({}); + (setDoc as Mock).mockResolvedValue(undefined); + + await service.setEvent(user, event); + + expect(setDoc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + name: 'My Event', + originalFiles: event.originalFiles, + originalFile: event.originalFile, + }), + { merge: true } + ); + }); + + it('should atomically write activity and event in writeActivityAndEventData', async () => { + const user = { uid: 'user1' } as any; + const event = { + getID: () => 'event1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + toJSON: () => ({ name: 'My Event' }), + originalFile: { path: 'users/user1/events/event1/original.fit', startDate: new Date('2026-01-01T00:00:00.000Z') }, + } as any; + const activity = { + getID: () => 'activity1', + toJSON: () => ({ + creator: { name: 'Device A' }, + streams: [{ type: 'Pace', values: [1, 2, 3] }] + }), + } as any; + + (doc as Mock).mockReturnValue({}); + + await service.writeActivityAndEventData(user, event, activity); + + expect(writeBatch).toHaveBeenCalledTimes(1); + expect(mocks.batchSet).toHaveBeenCalledTimes(2); + expect(mocks.batchSet).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + creator: { name: 'Device A' }, + userID: 'user1', + eventID: 'event1', + eventStartDate: event.startDate, + }), + { merge: true } + ); + const firstPayload = mocks.batchSet.mock.calls[0][1]; + expect(firstPayload.streams).toBeUndefined(); + + expect(mocks.batchSet).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + name: 'My Event', + originalFile: event.originalFile, + }), + { merge: true } + ); + expect(mocks.batchCommit).toHaveBeenCalledTimes(1); + }); + it('should call EventWriter in writeAllEventData', async () => { const mockEvent = { getID: () => '1', @@ -716,6 +912,38 @@ describe('AppEventService', () => { expect(parsedActivity.setID).toHaveBeenCalledWith(activityId); }); + it('should preserve renamed creator name from existing activity during client-side parsing', async () => { + const activityId = 'act1'; + + const existingActivity = { + getID: vi.fn().mockReturnValue(activityId), + creator: { name: 'Renamed Device' }, + } as any; + + const mockEvent = { + getActivities: vi.fn().mockReturnValue([existingActivity]), + originalFile: { path: 'path/to/file.fit' }, + getID: vi.fn().mockReturnValue('event1') + } as any; + + const parsedActivity = { + getID: vi.fn().mockReturnValue(null), + setID: vi.fn().mockReturnThis(), + creator: { name: 'Original Parsed Name' }, + } as any; + const parsedEvent = { + getActivities: vi.fn().mockReturnValue([parsedActivity]), + } as any; + + vi.spyOn(service as any, 'fetchAndParseOneFile').mockResolvedValue(parsedEvent); + + const result = await (service as any).calculateStreamsFromWithOrchestration(mockEvent); + + expect(result).toBe(parsedEvent); + expect(parsedActivity.setID).toHaveBeenCalledWith(activityId); + expect(parsedActivity.creator.name).toBe('Renamed Device'); + }); + it('should transfer activity IDs in merged events scenario (Multiple Files)', async () => { // Firestore activities const mockActivity1 = { getID: () => 'act1' } as any; @@ -825,4 +1053,337 @@ describe('AppEventService', () => { expect(parsedActivity1.setID).not.toHaveBeenCalled(); }); }); + + describe('delegation', () => { + it('should throw when event has no original file metadata', async () => { + const event = { + getID: () => 'event-1', + originalFile: undefined, + originalFiles: [], + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(firstValueFrom( + service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event), + )).rejects.toThrow('No original source file metadata found for event hydration.'); + }); + + it('should delegate downloadFile to AppOriginalFileHydrationService', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const expectedBuffer = new ArrayBuffer(4); + vi.spyOn(hydrationService, 'downloadFile').mockResolvedValue(expectedBuffer); + + const result = await service.downloadFile('users/u1/events/e1/original.fit'); + + expect(hydrationService.downloadFile).toHaveBeenCalledWith('users/u1/events/e1/original.fit'); + expect(result).toBe(expectedBuffer); + }); + + it('should delegate attachStreamsToEventWithActivities to parsing and attach streams only by default', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const oldAscentStat = { getValue: () => 280.8 }; + const parsedStreams = [{ type: 'Speed' }, { type: 'Distance' }] as any[]; + const existingActivity = { + getID: () => 'a-1', + clearStreams: vi.fn(), + addStreams: vi.fn(), + getStat: vi.fn().mockImplementation((type: string) => type === 'Ascent' ? oldAscentStat : undefined), + } as any; + const parsedActivity = { + getID: () => 'a-1', + getAllStreams: vi.fn().mockReturnValue(parsedStreams), + } as any; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([parsedActivity]), + } as any; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [] + }); + + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + getActivities: vi.fn().mockReturnValue([existingActivity]), + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + + const originalAscentStat = existingActivity.getStat('Ascent'); + const result = await firstValueFrom(service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event)); + + expect(hydrationService.parseEventFromOriginalFiles).toHaveBeenCalledWith( + event, + expect.objectContaining({ + strictAllFilesRequired: true, + preserveActivityIdsFromEvent: true, + mergeMultipleFiles: true, + }), + ); + expect(existingActivity.clearStreams).toHaveBeenCalledTimes(1); + expect(existingActivity.addStreams).toHaveBeenCalledWith(parsedStreams); + expect(existingActivity.getStat('Ascent')).toBe(originalAscentStat); + expect(event.clearActivities).not.toHaveBeenCalled(); + expect(event.addActivities).not.toHaveBeenCalled(); + expect(result).toBe(event); + }); + + it('should respect streamTypes filter when attaching streams in stream-only mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const parsedStreams = [{ type: 'Speed' }, { type: 'Distance' }, { type: 'Power' }] as any[]; + const existingActivity = { + getID: () => 'a-1', + clearStreams: vi.fn(), + addStreams: vi.fn(), + } as any; + const parsedActivity = { + getID: () => 'a-1', + getAllStreams: vi.fn().mockReturnValue(parsedStreams), + } as any; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([parsedActivity]), + } as any; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [], + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + getActivities: vi.fn().mockReturnValue([existingActivity]), + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + + await firstValueFrom(service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event, ['Distance', 'Power'])); + + expect(existingActivity.clearStreams).toHaveBeenCalledTimes(1); + expect(existingActivity.addStreams).toHaveBeenCalledWith([{ type: 'Distance' }, { type: 'Power' }]); + expect(event.clearActivities).not.toHaveBeenCalled(); + expect(event.addActivities).not.toHaveBeenCalled(); + }); + + it('should attach matched IDs only and warn on ID mismatch in stream-only mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const existingActivityA = { + getID: () => 'a-1', + clearStreams: vi.fn(), + addStreams: vi.fn(), + } as any; + const existingActivityB = { + getID: () => 'a-2', + clearStreams: vi.fn(), + addStreams: vi.fn(), + } as any; + const parsedActivityA = { + getID: () => 'a-1', + getAllStreams: vi.fn().mockReturnValue([{ type: 'Speed' }]), + } as any; + const parsedActivityOther = { + getID: () => 'b-9', + getAllStreams: vi.fn().mockReturnValue([{ type: 'Power' }]), + } as any; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([parsedActivityA, parsedActivityOther]), + } as any; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [], + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + getActivities: vi.fn().mockReturnValue([existingActivityA, existingActivityB]), + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + + await firstValueFrom(service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event)); + + expect(existingActivityA.clearStreams).toHaveBeenCalledTimes(1); + expect(existingActivityA.addStreams).toHaveBeenCalledWith([{ type: 'Speed' }]); + expect(existingActivityB.clearStreams).not.toHaveBeenCalled(); + expect(existingActivityB.addStreams).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + '[AppEventService] Stream-only hydration attached matched activity IDs only', + expect.objectContaining({ + eventID: 'event-1', + unmatchedExistingActivityIDs: ['a-2'], + unmatchedParsedActivityIDs: ['b-9'], + }), + ); + }); + + it('should replace activities when hydrationMode is replace_activities', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const parsedActivity = { getID: () => 'a-1' } as any; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([parsedActivity]), + } as any; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [], + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + getActivities: vi.fn().mockReturnValue([]), + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + + const result = await firstValueFrom( + service.attachStreamsToEventWithActivities( + { uid: 'u1' } as any, + event, + undefined, + true, + false, + 'replace_activities', + ), + ); + + expect(event.clearActivities).toHaveBeenCalledTimes(1); + expect(event.addActivities).toHaveBeenCalledWith([parsedActivity]); + expect(result).toBe(event); + expect(hydrationService.parseEventFromOriginalFiles).toHaveBeenCalledWith( + event, + expect.objectContaining({ + strictAllFilesRequired: true, + }), + ); + }); + + it('should return parsed event directly when merge=false', async () => { + const hydrationService = (service as any).originalFileHydrationService; + const parsedEvent = { + setID: vi.fn().mockReturnThis(), + getActivities: vi.fn().mockReturnValue([{ getID: () => 'a-1' }]), + } as any; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: parsedEvent, + parsedEvents: [parsedEvent], + sourceFilesCount: 1, + failedFiles: [] + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + clearActivities: vi.fn(), + addActivities: vi.fn(), + } as any; + + const result = await firstValueFrom(service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event, undefined, false)); + + expect(parsedEvent.setID).toHaveBeenCalledWith('event-1'); + expect(result).toBe(parsedEvent); + expect(event.clearActivities).not.toHaveBeenCalled(); + expect(hydrationService.parseEventFromOriginalFiles).toHaveBeenCalledWith( + event, + expect.objectContaining({ + strictAllFilesRequired: true, + }), + ); + }); + + it('should rethrow when parser throws in stream-only mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockRejectedValue(new Error('parse blew up')); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(firstValueFrom( + service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event), + )).rejects.toThrow('parse blew up'); + }); + + it('should rethrow when parser throws in replace_activities mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockRejectedValue(new Error('parse blew up')); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(firstValueFrom( + service.attachStreamsToEventWithActivities( + { uid: 'u1' } as any, + event, + undefined, + true, + false, + 'replace_activities', + ), + )).rejects.toThrow('parse blew up'); + }); + + it('should rethrow when parser returns no finalEvent in stream-only mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: null, + parsedEvents: [], + sourceFilesCount: 1, + failedFiles: [{ path: 'users/u1/events/e1/original.fit', reason: 'fail' }], + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(firstValueFrom( + service.attachStreamsToEventWithActivities({ uid: 'u1' } as any, event), + )).rejects.toThrow('Could not build event from original source files'); + }); + + it('should rethrow when parser returns no finalEvent in replace_activities mode', async () => { + const hydrationService = (service as any).originalFileHydrationService; + vi.spyOn(hydrationService, 'parseEventFromOriginalFiles').mockResolvedValue({ + finalEvent: null, + parsedEvents: [], + sourceFilesCount: 1, + failedFiles: [{ path: 'users/u1/events/e1/original.fit', reason: 'fail' }], + }); + const event = { + getID: () => 'event-1', + originalFile: { path: 'users/u1/events/e1/original.fit' }, + clearActivities: vi.fn(), + addActivities: vi.fn(), + getActivities: vi.fn().mockReturnValue([]), + } as any; + + await expect(firstValueFrom( + service.attachStreamsToEventWithActivities( + { uid: 'u1' } as any, + event, + undefined, + true, + false, + 'replace_activities', + ), + )).rejects.toThrow('Could not build event from original source files'); + }); + }); }); diff --git a/src/app/services/app.event.service.ts b/src/app/services/app.event.service.ts index e9c295fe6..2a1dcbf53 100644 --- a/src/app/services/app.event.service.ts +++ b/src/app/services/app.event.service.ts @@ -1,14 +1,12 @@ import { inject, Injectable, Injector, OnDestroy, runInInjectionContext } from '@angular/core'; import { EventInterface } from '@sports-alliance/sports-lib'; -import { ActivityParsingOptions } from '@sports-alliance/sports-lib'; import { EventImporterJSON } from '@sports-alliance/sports-lib'; -import { combineLatest, from, Observable, of, zip } from 'rxjs'; -import { Firestore, collection, query, orderBy, where, limit, startAfter, endBefore, collectionData, doc, docData, getDoc, getDocs, setDoc, updateDoc, deleteDoc, writeBatch, DocumentSnapshot, QueryDocumentSnapshot, CollectionReference, getCountFromServer } from '@angular/fire/firestore'; -import { catchError, map, switchMap, take, distinctUntilChanged } from 'rxjs/operators'; +import { combineLatest, from, Observable, of, throwError, zip } from 'rxjs'; +import { Firestore, collection, query, orderBy, where, limit, startAfter, endBefore, collectionData, onSnapshot, doc, docData, getDoc, getDocs, getDocsFromCache, setDoc, updateDoc, deleteDoc, writeBatch, DocumentSnapshot, QueryDocumentSnapshot, CollectionReference, Query, QuerySnapshot, DocumentData, getCountFromServer } from '@angular/fire/firestore'; +import { catchError, map, switchMap, take, distinctUntilChanged, tap } from 'rxjs/operators'; import { EventJSONInterface } from '@sports-alliance/sports-lib'; import { ActivityJSONInterface } from '@sports-alliance/sports-lib'; import { ActivityInterface } from '@sports-alliance/sports-lib'; -import { StreamInterface } from '@sports-alliance/sports-lib'; import { EventExporterJSON } from '@sports-alliance/sports-lib'; import { User } from '@sports-alliance/sports-lib'; import { Privacy } from '@sports-alliance/sports-lib'; @@ -22,6 +20,7 @@ import { EventExporterGPX } from '@sports-alliance/sports-lib'; import { EventWriter, FirestoreAdapter, StorageAdapter, OriginalFile } from '../../../functions/src/shared/event-writer'; import { generateActivityID, generateEventID } from '../../../functions/src/shared/id-generator'; +import { createParsingOptions } from '../../../functions/src/shared/parsing-options'; import { Bytes, serverTimestamp } from 'firebase/firestore'; import { Storage, ref, uploadBytes, getBytes } from '@angular/fire/storage'; import { EventImporterSuuntoJSON } from '@sports-alliance/sports-lib'; @@ -46,6 +45,22 @@ import { getMetadata } from '@angular/fire/storage'; import { AppCacheService } from './app.cache.service'; import { BenchmarkEventAdapter } from './benchmark-event.adapter'; import { SPORTS_LIB_VERSION } from '../constants/sports-lib-version.browser'; +import { buildActivityEditWritePayload, buildActivityWriteData, buildEventWriteData } from '../utils/activity-edit.persistence'; +import { AppOriginalFileHydrationService } from './app.original-file-hydration.service'; + +export interface GetEventsOnceOptions { + preferCache?: boolean; + warmServer?: boolean; +} + +export type EventsOnceSource = 'cache' | 'server'; + +export interface GetEventsOnceResult { + events: EventInterface[]; + source: EventsOnceSource; +} + +export type StreamHydrationMode = 'attach_streams_only' | 'replace_activities'; @Injectable({ @@ -60,6 +75,7 @@ export class AppEventService implements OnDestroy { private logger = inject(LoggerService); private appEventUtilities = inject(AppEventUtilities); private benchmarkAdapter = inject(BenchmarkEventAdapter); + private originalFileHydrationService = inject(AppOriginalFileHydrationService); private static readonly DEDUPE_TTL_MS = 6 * 60 * 60 * 1000; private static readonly SANITIZER_EVENT_TTL_MS = 30 * 60 * 1000; private static readonly DEDUPE_UNKNOWN_TYPES_MAX = 500; @@ -124,45 +140,238 @@ export class AppEventService implements OnDestroy { return ['activity-sanitizer', eventID, activityID, ...kinds]; } - public getEventAndActivities(user: User, eventID: string): Observable { - // See - // https://stackoverflow.com/questions/42939978/avoiding-nested-subscribes-with-combine-latest-when-one-observable-depends-on-th - const eventDoc = runInInjectionContext(this.injector, () => doc(this.firestore, 'users', user.uid, 'events', eventID)); - return combineLatest([ - runInInjectionContext(this.injector, () => docData(eventDoc)).pipe( - map(eventSnapshot => { - if (!eventSnapshot) return null; - const { sanitizedJson } = EventJSONSanitizer.sanitize(eventSnapshot); - const event = EventImporterJSON.getEventFromJSON(sanitizedJson).setID(eventID) as AppEventInterface; + private buildSnapshotFingerprint(snapshot: any): string { + try { + return JSON.stringify(snapshot ?? null); + } catch { + return `${snapshot}`; + } + } - // Hydrate with original file(s) info if present - const rawData = eventSnapshot as any; + private getActivityIDsForDebug(activities: Array | any> | null | undefined): string[] { + return (activities || []).map((activity: any, index: number) => + typeof activity?.getID === 'function' ? activity.getID() : `idx-${index}-no-id`, + ); + } - if (rawData.originalFiles) { - event.originalFiles = rawData.originalFiles.map((file: any) => { - if (file.startDate) { - // Convert Firestore Timestamp to Date - if (file.startDate.toDate && typeof file.startDate.toDate === 'function') { - file.startDate = file.startDate.toDate(); - } else if (file.startDate.seconds !== undefined) { - file.startDate = new Date(file.startDate.seconds * 1000 + (file.startDate.nanoseconds || 0) / 1000000); - } else if (typeof file.startDate === 'string') { - file.startDate = new Date(file.startDate); - } - } else { - throw new Error('Event Metadata Error: Missing startDate for file ' + file.path); - } - return file; - }); + private buildEventFromSnapshot(eventSnapshot: any, eventID: string): AppEventInterface | null { + if (!eventSnapshot) return null; + const { sanitizedJson } = EventJSONSanitizer.sanitize(eventSnapshot); + const event = EventImporterJSON.getEventFromJSON(sanitizedJson).setID(eventID) as AppEventInterface; + + // Hydrate with original file(s) info if present + const rawData = eventSnapshot as any; + + if (rawData.originalFiles) { + event.originalFiles = rawData.originalFiles.map((file: any) => { + if (file.startDate) { + // Convert Firestore Timestamp to Date + if (file.startDate.toDate && typeof file.startDate.toDate === 'function') { + file.startDate = file.startDate.toDate(); + } else if (file.startDate.seconds !== undefined) { + file.startDate = new Date(file.startDate.seconds * 1000 + (file.startDate.nanoseconds || 0) / 1000000); + } else if (typeof file.startDate === 'string') { + file.startDate = new Date(file.startDate); } - if (rawData.originalFile) { - event.originalFile = rawData.originalFile; + } else { + throw new Error('Event Metadata Error: Missing startDate for file ' + file.path); + } + return file; + }); + } + if (rawData.originalFile) { + event.originalFile = rawData.originalFile; + } + + this.benchmarkAdapter.applyBenchmarkFieldsFromFirestore(event, rawData); + + return event; + } + + private cloneEventWithActivities(event: AppEventInterface, activities: ActivityInterface[]): AppEventInterface { + const eventAny = event as any; + let clonedEvent: AppEventInterface; + + if (typeof eventAny.toJSON === 'function') { + clonedEvent = EventImporterJSON.getEventFromJSON(event.toJSON() as EventJSONInterface) + .setID(event.getID()) as AppEventInterface; + } else { + const clonedFallbackEvent = Object.assign( + Object.create(Object.getPrototypeOf(eventAny) || Object.prototype), + eventAny, + ) as AppEventInterface; + clonedEvent = clonedFallbackEvent; + if (typeof (clonedEvent as any).setID === 'function') { + (clonedEvent as any).setID(event.getID()); + } + } + + // Preserve original source file metadata on cloned instances. + if (event.originalFiles) { + clonedEvent.originalFiles = [...event.originalFiles]; + } + if (event.originalFile) { + clonedEvent.originalFile = event.originalFile; + } + + // Preserve benchmark fields needed by event details UI. + if ((event as any).benchmarkResults) { + (clonedEvent as any).benchmarkResults = { ...(event as any).benchmarkResults }; + } + if ((event as any).benchmarkResult) { + (clonedEvent as any).benchmarkResult = { ...(event as any).benchmarkResult }; + } + + if (typeof (clonedEvent as any).clearActivities === 'function' && typeof (clonedEvent as any).addActivities === 'function') { + clonedEvent.clearActivities(); + clonedEvent.addActivities(activities); + } else { + (clonedEvent as any).activities = [...activities]; + if (typeof (clonedEvent as any).getActivities !== 'function') { + (clonedEvent as any).getActivities = () => (clonedEvent as any).activities; + } + } + return clonedEvent; + } + + private buildEventDetailsFingerprint(event: AppEventInterface | null): string { + if (!event) { + return 'null'; + } + + const eventAny = event as any; + const eventSnapshot = typeof eventAny?.toJSON === 'function' ? eventAny.toJSON() : eventAny; + const activitySnapshots = (event.getActivities() || []).map((activity) => { + const activityAny = activity as any; + if (typeof activityAny?.toJSON === 'function') { + return activityAny.toJSON(); + } + return { + id: typeof activityAny?.getID === 'function' ? activityAny.getID() : null, + ...activityAny, + }; + }); + + return this.buildSnapshotFingerprint({ + eventID: typeof eventAny?.getID === 'function' ? eventAny.getID() : null, + event: eventSnapshot, + activities: activitySnapshots, + }); + } + + private parseActivitiesFromSnapshots(eventID: string, activitySnapshots: any[]): ActivityInterface[] { + return (activitySnapshots || []).reduce((activitiesArray: ActivityInterface[], activitySnapshot: any) => { + try { + // Ensure required properties exist for sports-lib 6.x compatibility + const safeActivityData = { + ...activitySnapshot, + stats: activitySnapshot.stats || {}, + laps: activitySnapshot.laps || [], + streams: activitySnapshot.streams || [], + intensityZones: activitySnapshot.intensityZones || [], + events: activitySnapshot.events || [] + }; + const { sanitizedJson, unknownTypes, issues } = EventJSONSanitizer.sanitize(safeActivityData); + if (unknownTypes.length > 0) { + const newUnknownTypes = unknownTypes.filter(type => AppEventService.shouldReportKey( + AppEventService.reportedUnknownTypes, + type, + AppEventService.DEDUPE_TTL_MS, + AppEventService.DEDUPE_UNKNOWN_TYPES_MAX + )); + if (newUnknownTypes.length > 0) { + this.logger.captureMessage('Unknown Data Types in getActivities', { extra: { types: newUnknownTypes, eventID, activityID: activitySnapshot.id } }); } + } + const actionableIssues = (issues || []).filter(issue => issue.kind !== 'unknown_data_type'); + if (actionableIssues.length > 0) { + const newIssues = actionableIssues.filter(issue => { + const issueKey = AppEventService.getSanitizerIssueKey(activitySnapshot.id, issue); + return AppEventService.shouldReportKey( + AppEventService.reportedSanitizerIssues, + issueKey, + AppEventService.DEDUPE_TTL_MS, + AppEventService.DEDUPE_SANITIZER_ISSUES_MAX + ); + }); - this.benchmarkAdapter.applyBenchmarkFieldsFromFirestore(event, rawData); + if (newIssues.length > 0) { + const issueSummary = AppEventService.summarizeIssues(newIssues); + const cappedIssues = newIssues.slice(0, AppEventService.MAX_ISSUES_PER_REPORT); + const issuesTruncated = Math.max(0, newIssues.length - cappedIssues.length); + this.logger.warn('[AppEventService] Sanitized malformed activity data', { + eventID, + activityID: activitySnapshot.id, + issueCount: newIssues.length, + issueSummary, + issuesTruncated, + issues: cappedIssues + }); - return event; - })), + const sentryEventKey = `${eventID}|${activitySnapshot.id}|${Object.keys(issueSummary).sort().join(',')}`; + const shouldReportSanitizerEvent = AppEventService.shouldReportKey( + AppEventService.reportedSanitizerEvents, + sentryEventKey, + AppEventService.SANITIZER_EVENT_TTL_MS, + AppEventService.DEDUPE_SANITIZER_EVENTS_MAX + ); + + if (shouldReportSanitizerEvent) { + this.logger.captureException(new Error('Sanitized malformed activity data in getActivities'), { + fingerprint: AppEventService.buildSanitizerFingerprint(eventID, activitySnapshot.id, newIssues), + extra: { + eventID, + activityID: activitySnapshot.id, + issueCount: newIssues.length, + issueSummary, + issuesTruncated, + issues: cappedIssues + } + }); + } + } + } + activitiesArray.push(EventImporterJSON.getActivityFromJSON(sanitizedJson).setID(activitySnapshot.id)); + } catch (e) { + this.logger.error('Failed to parse activity:', activitySnapshot.id, 'Error:', e); + } + return activitiesArray; + }, []); + } + + private getActivitiesForEventDetailsLive(user: User, eventID: string): Observable { + this.logger.log('[AppEventService] getActivitiesForEventDetailsLive subscribed', { userID: user.uid, eventID }); + const activitiesCollection = runInInjectionContext(this.injector, () => collection(this.firestore, 'users', user.uid, 'activities')); + const q = runInInjectionContext(this.injector, () => query(activitiesCollection, where('eventID', '==', eventID))); + return (runInInjectionContext(this.injector, () => collectionData(q, { idField: 'id' })) as Observable).pipe( + distinctUntilChanged((previousSnapshots, currentSnapshots) => + this.buildSnapshotFingerprint(previousSnapshots) === this.buildSnapshotFingerprint(currentSnapshots) + ), + map((activitySnapshots: any[]) => { + this.logger.log('[AppEventService] getActivitiesForEventDetailsLive Firestore emission', { + eventID, + snapshotCount: activitySnapshots?.length || 0, + }); + return this.parseActivitiesFromSnapshots(eventID, activitySnapshots); + }), + tap((activities) => { + this.logger.log('[AppEventService] getActivitiesForEventDetailsLive parsed activities', { + eventID, + activityCount: activities?.length || 0, + activityIDs: this.getActivityIDsForDebug(activities), + }); + }), + ); + } + + public getEventAndActivities(user: User, eventID: string): Observable { + this.logger.log('[AppEventService] getEventAndActivities subscribed', { userID: user.uid, eventID }); + // See + // https://stackoverflow.com/questions/42939978/avoiding-nested-subscribes-with-combine-latest-when-one-observable-depends-on-th + const eventDoc = runInInjectionContext(this.injector, () => doc(this.firestore, 'users', user.uid, 'events', eventID)); + return combineLatest([ + runInInjectionContext(this.injector, () => docData(eventDoc)).pipe( + map(eventSnapshot => this.buildEventFromSnapshot(eventSnapshot, eventID))), this.getActivities(user, eventID), ]).pipe( distinctUntilChanged((prev, curr) => { @@ -202,11 +411,16 @@ export class AppEventService implements OnDestroy { return of([null, null] as [AppEventInterface | null, ActivityInterface[] | null]); // @todo fix this })).pipe(map(([event, activities]: [AppEventInterface, ActivityInterface[]]) => { if (!event) { + this.logger.log('[AppEventService] getEventAndActivities emission with null event', { eventID }); return null; } - event.clearActivities(); - event.addActivities(activities); - return event; + const emittedEvent = this.cloneEventWithActivities(event, activities); + this.logger.log('[AppEventService] getEventAndActivities combined emission', { + eventID: emittedEvent.getID(), + activityCount: activities?.length || 0, + activityIDs: this.getActivityIDsForDebug(activities), + }); + return emittedEvent; })).pipe(catchError((error) => { // debugger; this.logger.error('Error adding activities to event:', error); @@ -215,6 +429,54 @@ export class AppEventService implements OnDestroy { })) } + /** + * Event details specific live stream. Keeps dashboard/event-list listeners untouched. + * Emits on event metadata changes and activity metadata changes. + */ + public getEventDetailsLive(user: User, eventID: string): Observable { + this.logger.log('[AppEventService] getEventDetailsLive subscribed', { userID: user.uid, eventID }); + const eventDoc = runInInjectionContext(this.injector, () => doc(this.firestore, 'users', user.uid, 'events', eventID)); + return combineLatest([ + runInInjectionContext(this.injector, () => docData(eventDoc)).pipe( + distinctUntilChanged((previousSnapshot, currentSnapshot) => + this.buildSnapshotFingerprint(previousSnapshot) === this.buildSnapshotFingerprint(currentSnapshot) + ), + map((eventSnapshot) => { + this.logger.log('[AppEventService] getEventDetailsLive event doc emission', { + eventID, + hasEventSnapshot: !!eventSnapshot, + }); + return this.buildEventFromSnapshot(eventSnapshot, eventID); + }) + ), + this.getActivitiesForEventDetailsLive(user, eventID), + ]).pipe( + map(([event, activities]: [AppEventInterface, ActivityInterface[]]) => { + if (!event) { + this.logger.log('[AppEventService] getEventDetailsLive combined emission with null event', { eventID }); + return null; + } + const emittedEvent = this.cloneEventWithActivities(event, activities); + this.logger.log('[AppEventService] getEventDetailsLive combined emission', { + eventID: emittedEvent.getID(), + activityCount: activities?.length || 0, + activityIDs: this.getActivityIDsForDebug(activities), + }); + return emittedEvent; + }), + distinctUntilChanged((previousEvent, currentEvent) => + this.buildEventDetailsFingerprint(previousEvent) === this.buildEventDetailsFingerprint(currentEvent) + ), + catchError((error) => { + if (error?.code === 'permission-denied') { + return of(null); + } + this.logger.error('Error fetching live event details:', error); + return of(null); + }), + ); + } + public getEventsBy(user: User, where: { fieldPath: string | any, opStr: any, value: any }[] = [], orderBy: string = 'startDate', asc: boolean = false, limit: number = 10, startAfter?: EventInterface, endBefore?: EventInterface): Observable { this.logger.log(`[AppEventService] getEventsBy called for user: ${user.uid}, where: ${JSON.stringify(where)}`); if (startAfter || endBefore) { @@ -223,39 +485,42 @@ export class AppEventService implements OnDestroy { return this._getEvents(user, where, orderBy, asc, limit); } - public getEventsOnceBy(user: User, whereClauses: { fieldPath: string | any, opStr: any, value: any }[] = [], orderByField: string = 'startDate', asc: boolean = false, limitCount: number = 10): Observable { - const q = this.getEventQueryForUser(user, whereClauses, orderByField, asc, limitCount); - return from(runInInjectionContext(this.injector, () => getDocs(q))).pipe(map((querySnapshot) => { - return querySnapshot.docs.map((queryDocumentSnapshot) => { - const eventSnapshot = queryDocumentSnapshot.data(); - const { sanitizedJson, unknownTypes } = EventJSONSanitizer.sanitize(eventSnapshot); - if (unknownTypes.length > 0) { - const newUnknownTypes = unknownTypes.filter(type => AppEventService.shouldReportKey( - AppEventService.reportedUnknownTypes, - type, - AppEventService.DEDUPE_TTL_MS, - AppEventService.DEDUPE_UNKNOWN_TYPES_MAX - )); - if (newUnknownTypes.length > 0) { - this.logger.captureMessage('Unknown Data Types in getEventsOnceBy', { extra: { types: newUnknownTypes, eventID: queryDocumentSnapshot.id } }); - } - } - const event = EventImporterJSON.getEventFromJSON(sanitizedJson).setID(queryDocumentSnapshot.id) as AppEventInterface; + public getEventsOnceBy( + user: User, + whereClauses: { fieldPath: string | any, opStr: any, value: any }[] = [], + orderByField: string = 'startDate', + asc: boolean = false, + limitCount: number = 10, + options: GetEventsOnceOptions = {} + ): Observable { + return this.getEventsOnceByWithMeta( + user, + whereClauses, + orderByField, + asc, + limitCount, + options + ).pipe(map(result => result.events)); + } - // Hydrate with original file(s) info if present - const rawData = eventSnapshot as any; - if (rawData.originalFiles) { - event.originalFiles = rawData.originalFiles; - } - if (rawData.originalFile) { - event.originalFile = rawData.originalFile; - } + public getEventsOnceByWithMeta( + user: User, + whereClauses: { fieldPath: string | any, opStr: any, value: any }[] = [], + orderByField: string = 'startDate', + asc: boolean = false, + limitCount: number = 10, + options: GetEventsOnceOptions = {} + ): Observable { + const q = this.getEventQueryForUser(user, whereClauses, orderByField, asc, limitCount); + const queryStart = performance.now(); + const preferCache = options.preferCache === true; + const warmServer = options.warmServer === true; - this.benchmarkAdapter.applyBenchmarkFieldsFromFirestore(event, rawData); + if (!preferCache) { + return from(this.fetchEventsOnceFromServer(q, user.uid, queryStart)); + } - return event; - }); - })); + return from(this.fetchEventsOnceCacheFirst(q, user.uid, queryStart, warmServer)); } /** @@ -282,6 +547,11 @@ export class AppEventService implements OnDestroy { * @param streamTypes */ public getEventActivitiesAndSomeStreams(user: User, eventID: string, streamTypes: string[]) { + this.logger.log('[AppEventService] getEventActivitiesAndSomeStreams called', { + userID: user.uid, + eventID, + streamTypes, + }); return this._getEventActivitiesAndAllOrSomeStreams(user, eventID, streamTypes); } @@ -301,83 +571,7 @@ export class AppEventService implements OnDestroy { return (runInInjectionContext(this.injector, () => collectionData(q, { idField: 'id' })) as Observable).pipe( map((activitySnapshots: any[]) => { this.logger.log(`[AppEventService] getActivities emitted ${activitySnapshots?.length || 0} activity snapshots for event: ${eventID}`); - return activitySnapshots.reduce((activitiesArray: ActivityInterface[], activitySnapshot: any) => { - try { - // Ensure required properties exist for sports-lib 6.x compatibility - const safeActivityData = { - ...activitySnapshot, - stats: activitySnapshot.stats || {}, - laps: activitySnapshot.laps || [], - streams: activitySnapshot.streams || [], - intensityZones: activitySnapshot.intensityZones || [], - events: activitySnapshot.events || [] - }; - const { sanitizedJson, unknownTypes, issues } = EventJSONSanitizer.sanitize(safeActivityData); - if (unknownTypes.length > 0) { - const newUnknownTypes = unknownTypes.filter(type => AppEventService.shouldReportKey( - AppEventService.reportedUnknownTypes, - type, - AppEventService.DEDUPE_TTL_MS, - AppEventService.DEDUPE_UNKNOWN_TYPES_MAX - )); - if (newUnknownTypes.length > 0) { - this.logger.captureMessage('Unknown Data Types in getActivities', { extra: { types: newUnknownTypes, eventID, activityID: activitySnapshot.id } }); - } - } - const actionableIssues = (issues || []).filter(issue => issue.kind !== 'unknown_data_type'); - if (actionableIssues.length > 0) { - const newIssues = actionableIssues.filter(issue => { - const issueKey = AppEventService.getSanitizerIssueKey(activitySnapshot.id, issue); - return AppEventService.shouldReportKey( - AppEventService.reportedSanitizerIssues, - issueKey, - AppEventService.DEDUPE_TTL_MS, - AppEventService.DEDUPE_SANITIZER_ISSUES_MAX - ); - }); - - if (newIssues.length > 0) { - const issueSummary = AppEventService.summarizeIssues(newIssues); - const cappedIssues = newIssues.slice(0, AppEventService.MAX_ISSUES_PER_REPORT); - const issuesTruncated = Math.max(0, newIssues.length - cappedIssues.length); - this.logger.warn('[AppEventService] Sanitized malformed activity data', { - eventID, - activityID: activitySnapshot.id, - issueCount: newIssues.length, - issueSummary, - issuesTruncated, - issues: cappedIssues - }); - - const sentryEventKey = `${eventID}|${activitySnapshot.id}|${Object.keys(issueSummary).sort().join(',')}`; - const shouldReportSanitizerEvent = AppEventService.shouldReportKey( - AppEventService.reportedSanitizerEvents, - sentryEventKey, - AppEventService.SANITIZER_EVENT_TTL_MS, - AppEventService.DEDUPE_SANITIZER_EVENTS_MAX - ); - - if (shouldReportSanitizerEvent) { - this.logger.captureException(new Error('Sanitized malformed activity data in getActivities'), { - fingerprint: AppEventService.buildSanitizerFingerprint(eventID, activitySnapshot.id, newIssues), - extra: { - eventID, - activityID: activitySnapshot.id, - issueCount: newIssues.length, - issueSummary, - issuesTruncated, - issues: cappedIssues - } - }); - } - } - } - activitiesArray.push(EventImporterJSON.getActivityFromJSON(sanitizedJson).setID(activitySnapshot.id)); - } catch (e) { - this.logger.error('Failed to parse activity:', activitySnapshot.id, 'Error:', e); - } - return activitiesArray; - }, []); + return this.parseActivitiesFromSnapshots(eventID, activitySnapshots); }), ) } @@ -398,54 +592,6 @@ export class AppEventService implements OnDestroy { ); } - /** - * @deprecated Streams are no longer stored in Firestore. Use attachStreamsToEventWithActivities instead. - */ - public getAllStreams(user: User, eventID: string, activityID: string): Observable { - this.logger.warn('[AppEventService] getAllStreams is deprecated and will likely return empty results.'); - const streamsCollection = runInInjectionContext(this.injector, () => collection(this.firestore, 'users', user.uid, 'activities', activityID, 'streams')); - return from(runInInjectionContext(this.injector, () => getDocs(streamsCollection))) // @todo replace with snapshot changes I suppose when @https://github.com/angular/angularfire2/issues/1552 is fixed - .pipe(map((querySnapshot) => { - return querySnapshot.docs.map(queryDocumentSnapshot => this.processStreamQueryDocumentSnapshot(queryDocumentSnapshot)) - })) - } - - /** - * @deprecated Streams are no longer stored in Firestore. Use attachStreamsToEventWithActivities instead. - */ - public getStream(user: User, eventID: string, activityID: string, streamType: string): Observable { - this.logger.warn('[AppEventService] getStream is deprecated and will likely return empty results.'); - return from(runInInjectionContext(this.injector, () => getDoc(doc(this.firestore, 'users', user.uid, 'activities', activityID, 'streams', streamType)))) - .pipe(map((queryDocumentSnapshot) => { - // getDoc returns DocumentSnapshot, ensure data exists - if (!queryDocumentSnapshot.exists()) return null; // Handle missing stream - return this.processStreamDocumentSnapshot(queryDocumentSnapshot) // DocumentSnapshot is a DocumentData - })) - } - - public getStreamsByTypes(userID: string, eventID: string, activityID: string, types: string[]): Observable { - types = [...new Set(types)] - // if >10 to be split into x batches of work and use merge due to firestore not taking only up to 10 in in operator - const batchSize = 10 // Firstore limitation - const x = types.reduce((all, one, i) => { - const ch = Math.floor(i / batchSize); - all[ch] = [].concat((all[ch] || []), one); - return all - }, []).map((typesBatch) => { - const streamsCollection = runInInjectionContext(this.injector, () => collection(this.firestore, 'users', userID, 'activities', activityID, 'streams')); - const q = runInInjectionContext(this.injector, () => query(streamsCollection, where('type', 'in', typesBatch))); - return from(runInInjectionContext(this.injector, () => getDocs(q))) - .pipe(map((documentSnapshots) => { - return documentSnapshots.docs.reduce((streamArray: StreamInterface[], documentSnapshot) => { - streamArray.push(this.processStreamDocumentSnapshot(documentSnapshot)); - return streamArray; - }, []); - })) - }) - - return combineLatest(x).pipe(map(arrayOfArrays => arrayOfArrays.reduce((a, b) => a.concat(b), []))); - } - public async writeAllEventData(user: User, event: AppEventInterface, originalFiles?: OriginalFile[] | OriginalFile) { // 0. Ensure deterministic IDs to prevent duplicates // Frontend uploads use thresholdMs=0 for exact timestamps (no bucketing) @@ -564,19 +710,32 @@ export class AppEventService implements OnDestroy { } public async setEvent(user: User, event: EventInterface) { - return runInInjectionContext(this.injector, () => setDoc(doc(this.firestore, 'users', user.uid, 'events', event.getID()), event.toJSON())); + const eventData = buildEventWriteData(event); + return runInInjectionContext(this.injector, () => setDoc(doc(this.firestore, 'users', user.uid, 'events', event.getID()), eventData, { merge: true })); } public async setActivity(user: User, event: EventInterface, activity: ActivityInterface) { - const data = activity.toJSON() as any; - data.eventID = event.getID(); - data.userID = user.uid; - if (event.startDate) { - data.eventStartDate = event.startDate; - } + const data = buildActivityWriteData(user.uid, event, activity); return runInInjectionContext(this.injector, () => setDoc(doc(this.firestore, 'users', user.uid, 'activities', activity.getID()), data)); } + /** + * Atomic activity+event write for edit flows. + * This prevents partial success when updating both entities. + */ + public async writeActivityAndEventData(user: User, event: EventInterface, activity: ActivityInterface): Promise { + const { activityData, eventData } = buildActivityEditWritePayload(user.uid, event, activity); + return runInInjectionContext(this.injector, async () => { + const batch = writeBatch(this.firestore); + const activityRef = doc(this.firestore, 'users', user.uid, 'activities', activity.getID()); + const eventRef = doc(this.firestore, 'users', user.uid, 'events', event.getID()); + + batch.set(activityRef, activityData, { merge: true }); + batch.set(eventRef, eventData, { merge: true }); + await batch.commit(); + }); + } + public async updateEventProperties(user: User, eventID: string, propertiesToUpdate: any) { // @todo check if properties are allowed on object via it's JSON export interface keys return runInInjectionContext(this.injector, () => updateDoc(doc(this.firestore, 'users', user.uid, 'events', eventID), propertiesToUpdate)); @@ -645,105 +804,133 @@ export class AppEventService implements OnDestroy { * @param user * @param event * @param streamTypes + * @param merge + * @param skipEnrichment + * @param hydrationMode * @private */ - public attachStreamsToEventWithActivities(user: User, event: AppEventInterface, streamTypes?: string[], merge: boolean = true, skipEnrichment: boolean = false): Observable { - // Original File Reading Strategy: - // --------------------------------- - // Events store original file metadata in two fields (written by EventWriter): - // - originalFiles (array): Canonical source, always an array even for single files - // - originalFile (object): Legacy pointer to first file, for backwards compatibility - // - // Priority: Check originalFiles first (handles both merged events and normalized single-file cases) - // Fallback: Check originalFile only for older events written before the normalization was added - // - // See EventWriter.writeAllEventData() JSDoc for the full dual-field strategy explanation. + public attachStreamsToEventWithActivities( + user: User, + event: AppEventInterface, + streamTypes?: string[], + merge: boolean = true, + skipEnrichment: boolean = false, + hydrationMode: StreamHydrationMode = 'attach_streams_only', + ): Observable { this.logger.log(`[AppEventService] attachStreams for ${event.getID()}. originalFile: ${!!event.originalFile}, originalFiles: ${!!event.originalFiles}`); + const hasOriginalFiles = (event.originalFiles && event.originalFiles.length > 0) + || (event.originalFile && event.originalFile.path); - // Primary path: Use originalFiles array (canonical source) - if (event.originalFiles && event.originalFiles.length > 0) { - this.logger.log('[AppEventService] Using client-side parsing for (Multiple)', event.getID()); - return from(this.calculateStreamsFromWithOrchestration(event, skipEnrichment)).pipe( - map((fullEvent) => { - if (!fullEvent) return event; - - if (merge === false) { - // Return fresh event (disposable) - // We need to ensure it has an ID matching the requested one for consistency, though export might not care. - fullEvent.setID(event.getID()); - if (event.startDate) { - // Try to preserve start date if needed - // fullEvent.startDate = event.startDate; // EventInterface might not have setter, but let's assume it's fine - } - return fullEvent; - } - - event.clearActivities(); - event.addActivities(fullEvent.getActivities()); - return event; - }), - catchError((e) => { - this.logger.error('Failed to parse original files, falling back to legacy streams', e); - return this.attachStreamsLegacy(user, event, streamTypes); - }) - ); + if (!hasOriginalFiles) { + this.logger.error('[AppEventService] Failed to hydrate event due to missing original source file metadata', { + eventID: event.getID(), + }); + return throwError(() => new Error('No original source file metadata found for event hydration.')); } - // Legacy fallback: Use originalFile for events written before dual-field normalization - if (event.originalFile && event.originalFile.path) { - this.logger.log('[AppEventService] Using client-side parsing for (Single)', event.getID()); - return from(this.calculateStreamsFromWithOrchestration(event, skipEnrichment)).pipe( - map((fullEvent) => { - if (!fullEvent) return event; + return from(this.originalFileHydrationService.parseEventFromOriginalFiles(event, { + skipEnrichment, + strictAllFilesRequired: true, + preserveActivityIdsFromEvent: true, + mergeMultipleFiles: true, + })).pipe( + map((parseResult) => { + const fullEvent = parseResult.finalEvent; + if (!fullEvent) { + throw new Error('Could not build event from original source files'); + } - if (merge === false) { - fullEvent.setID(event.getID()); - return fullEvent; - } + if (merge === false) { + fullEvent.setID(event.getID()); + return fullEvent; + } - // Merge logic: Copy activities/streams from fullEvent to event - // We assume the file is the source of truth. - // Keep the ID and other metadata from Firestore, but replace activities + if (hydrationMode === 'replace_activities') { event.clearActivities(); event.addActivities(fullEvent.getActivities()); return event; - }), - catchError((e) => { - this.logger.error('Failed to parse original file, falling back to legacy streams', e); - return this.attachStreamsLegacy(user, event, streamTypes); - }) - ); - } + } - this.logger.log('[AppEventService] Fallback to legacy streams for', event.getID()); - return this.attachStreamsLegacy(user, event, streamTypes); + this.attachParsedStreamsToExistingActivities(event, fullEvent, streamTypes); + return event; + }), + catchError((error) => { + this.logger.error('[AppEventService] Failed to hydrate streams from original files', { + eventID: event.getID(), + hydrationMode, + error, + }); + return throwError(() => error); + }), + ); } - private attachStreamsLegacy(user: User, event: EventInterface, streamTypes?: string[]): Observable { - // Get all the streams for all activities and subscribe to them with latest emition for all streams - return combineLatest( - event.getActivities().map((activity) => { - return (streamTypes ? this.getStreamsByTypes(user.uid, event.getID(), activity.getID(), streamTypes) : this.getAllStreams(user, event.getID(), activity.getID())) - .pipe(map((streams) => { - streams = streams || []; - // debugger; - // This time we dont want to just get the streams but we want to attach them to the parent obj - activity.clearStreams(); - try { - activity.addStreams(streams); - } catch (e) { - if (e.message && e.message.indexOf('Duplicate type of stream') > -1) { - this.logger.warn('[attachStreamsLegacy] Duplicate stream warning:', e); - } else { - throw e; - } - } - // Return what we actually want to return not the streams - return event; - })); - })).pipe(map(([newEvent]) => { - return newEvent; - })); + private attachParsedStreamsToExistingActivities( + event: AppEventInterface, + parsedEvent: EventInterface, + streamTypes?: string[], + ): void { + const existingActivities = event.getActivities() || []; + const parsedActivities = parsedEvent.getActivities() || []; + const parsedActivitiesByID = new Map(); + const duplicateParsedIDs = new Set(); + let parsedActivitiesMissingID = 0; + + parsedActivities.forEach((parsedActivity) => { + const parsedActivityID = parsedActivity.getID(); + if (!parsedActivityID) { + parsedActivitiesMissingID += 1; + return; + } + if (parsedActivitiesByID.has(parsedActivityID)) { + duplicateParsedIDs.add(parsedActivityID); + } + parsedActivitiesByID.set(parsedActivityID, parsedActivity); + }); + + const unmatchedExistingActivityIDs: string[] = []; + let attachedCount = 0; + existingActivities.forEach((existingActivity) => { + const existingActivityID = existingActivity.getID(); + if (!existingActivityID) { + unmatchedExistingActivityIDs.push('(missing-id)'); + return; + } + const parsedActivity = parsedActivitiesByID.get(existingActivityID); + if (!parsedActivity) { + unmatchedExistingActivityIDs.push(existingActivityID); + return; + } + + const parsedStreams = parsedActivity.getAllStreams(); + const filteredStreams = streamTypes && streamTypes.length > 0 + ? parsedStreams.filter((stream) => streamTypes.includes(stream.type)) + : parsedStreams; + existingActivity.clearStreams(); + existingActivity.addStreams(filteredStreams); + parsedActivitiesByID.delete(existingActivityID); + attachedCount += 1; + }); + + const unmatchedParsedActivityIDs = Array.from(parsedActivitiesByID.keys()); + if ( + unmatchedExistingActivityIDs.length > 0 + || unmatchedParsedActivityIDs.length > 0 + || parsedActivitiesMissingID > 0 + || duplicateParsedIDs.size > 0 + ) { + this.logger.warn('[AppEventService] Stream-only hydration attached matched activity IDs only', { + eventID: event.getID(), + attachedCount, + existingActivitiesCount: existingActivities.length, + parsedActivitiesCount: parsedActivities.length, + unmatchedExistingActivityIDs, + unmatchedParsedActivityIDs, + parsedActivitiesMissingID, + duplicateParsedActivityIDs: Array.from(duplicateParsedIDs), + streamTypeFilter: streamTypes && streamTypes.length > 0 ? streamTypes : 'all', + }); + } } private async calculateStreamsFromWithOrchestration(event: AppEventInterface, skipEnrichment: boolean = false): Promise { @@ -766,6 +953,7 @@ export class AppEventService implements OnDestroy { finalEvent.getActivities().forEach((activity, index) => { if (existingActivities[index]) { activity.setID(existingActivities[index].getID()); + this.applyUserActivityOverrides(existingActivities[index], activity); } }); @@ -784,48 +972,32 @@ export class AppEventService implements OnDestroy { res.getActivities().forEach((activity, index) => { if (existingActivities[index]) { activity.setID(existingActivities[index].getID()); + this.applyUserActivityOverrides(existingActivities[index], activity); } }); } return res; } + /** + * Preserve user-edited fields from Firestore activity docs when source-file parsing rebuilds activities. + * Currently used for device rename persistence across reload. + */ + private applyUserActivityOverrides(existingActivity: ActivityInterface, parsedActivity: ActivityInterface): void { + if (!existingActivity || !parsedActivity) return; + + const existingCreatorName = `${existingActivity.creator?.name ?? ''}`.trim(); + if (existingCreatorName && parsedActivity.creator) { + parsedActivity.creator.name = existingCreatorName; + } + } + private cacheService = inject(AppCacheService); // ... (imports) public async downloadFile(path: string): Promise { - const fileRef = runInInjectionContext(this.injector, () => ref(this.storage, path)); - - try { - // 1. Fetch Metadata (fast) to get generation ID - const metadata = await runInInjectionContext(this.injector, () => getMetadata(fileRef)); - const generation = metadata.generation; - - // 2. Check Cache - const cached = await this.cacheService.getFile(path); - - if (cached && cached.generation === generation) { - this.logger.log(`[AppEventService] Cache HIT for ${path}`); - return this.fileService.decompressIfNeeded(cached.buffer, path); - } - - this.logger.log(`[AppEventService] Cache MISS/STALE for ${path} (Cloud Gen: ${generation}, Cached Gen: ${cached?.generation})`); - - // 3. Download fresh - const buffer = await runInInjectionContext(this.injector, () => getBytes(fileRef)); - - // 4. Update Cache - await this.cacheService.setFile(path, { buffer, generation }); - - return this.fileService.decompressIfNeeded(buffer, path); - - } catch (e) { - this.logger.error(`[AppEventService] Error downloading/caching file ${path}`, e); - // Fallback to direct download if metadata/cache fails - const buffer = await runInInjectionContext(this.injector, () => getBytes(fileRef)); - return this.fileService.decompressIfNeeded(buffer, path); - } + return this.originalFileHydrationService.downloadFile(path); } private async decompressIfNeeded(buffer: ArrayBuffer, path: string): Promise { @@ -845,9 +1017,7 @@ export class AppEventService implements OnDestroy { let newEvent: EventInterface; - const options = new ActivityParsingOptions({ - generateUnitStreams: false - }); + const options = createParsingOptions(); if (extension === 'fit') { newEvent = await EventImporterFIT.getFromArrayBuffer(arrayBuffer, options); @@ -897,12 +1067,28 @@ export class AppEventService implements OnDestroy { } private _getEventActivitiesAndAllOrSomeStreams(user: User, eventID, streamTypes?: string[]) { + this.logger.log('[AppEventService] _getEventActivitiesAndAllOrSomeStreams started', { + userID: user.uid, + eventID, + streamTypes: streamTypes || 'all', + }); return this.getEventAndActivities(user, eventID).pipe(switchMap((event) => { // Not sure about switch or merge if (!event) { + this.logger.log('[AppEventService] _getEventActivitiesAndAllOrSomeStreams received null event', { eventID }); return of(null); } + this.logger.log('[AppEventService] _getEventActivitiesAndAllOrSomeStreams attaching streams', { + eventID: event.getID(), + activityCount: event.getActivities()?.length || 0, + streamTypes: streamTypes || 'all', + }); // Get all the streams for all activities and subscribe to them with latest emition for all streams return this.attachStreamsToEventWithActivities(user, event, streamTypes) + }), tap((resultEvent) => { + this.logger.log('[AppEventService] _getEventActivitiesAndAllOrSomeStreams emission', { + eventID: resultEvent?.getID?.() ?? null, + activityCount: resultEvent?.getActivities?.()?.length || 0, + }); })) } @@ -938,13 +1124,14 @@ export class AppEventService implements OnDestroy { private _getEvents(user: User, whereClauses: { fieldPath: string | any, opStr: any, value: any }[] = [], orderByField: string = 'startDate', asc: boolean = false, limitCount: number = 10, startAfterDoc?: any, endBeforeDoc?: any): Observable { this.logger.log('[AppEventService] _getEvents fetching. user:', user.uid, 'where:', JSON.stringify(whereClauses)); - const q = this.getEventQueryForUser(user, whereClauses, orderByField, asc, limitCount, startAfterDoc, endBeforeDoc); + const q = this.getEventQueryForUser(user, whereClauses, orderByField, asc, limitCount, startAfterDoc, endBeforeDoc) as Query; + const queryStart = performance.now(); - return runInInjectionContext(this.injector, () => collectionData(q, { idField: 'id' })).pipe( - distinctUntilChanged((p, c) => JSON.stringify(p) === JSON.stringify(c)), + return this.listenToEventQueryData(q, user.uid, queryStart).pipe( map((eventSnapshots: any[]) => { + const deserializeStart = performance.now(); this.logger.log(`[AppEventService] _getEvents emitted ${eventSnapshots?.length || 0} event snapshots for user: ${user.uid}`); - return eventSnapshots.map((eventSnapshot) => { + const events = eventSnapshots.map((eventSnapshot) => { const { sanitizedJson, unknownTypes } = EventJSONSanitizer.sanitize(eventSnapshot); if (unknownTypes.length > 0) { const newUnknownTypes = unknownTypes.filter(type => AppEventService.shouldReportKey( @@ -972,10 +1159,182 @@ export class AppEventService implements OnDestroy { this.benchmarkAdapter.applyBenchmarkFieldsFromFirestore(event, rawData); return event; - }) + }); + this.logger.log('[perf] app_event_service_get_events_deserialize', { + durationMs: Number((performance.now() - deserializeStart).toFixed(2)), + snapshots: eventSnapshots?.length || 0, + userID: user.uid, + }); + return events; })); } + private listenToEventQueryData(q: Query, userID: string, queryStart: number): Observable { + return new Observable((subscriber) => { + let emissionCount = 0; + let hasEmitted = false; + + const unsubscribe = runInInjectionContext(this.injector, () => onSnapshot( + q, + { includeMetadataChanges: false }, + (querySnapshot: QuerySnapshot) => { + const docChanges = querySnapshot.docChanges({ includeMetadataChanges: false }); + + // Ignore follow-up snapshots that have zero document changes. + // These can occur during cache/server reconciliation and are duplicates for dashboard usage. + if (hasEmitted && docChanges.length === 0) { + this.logger.log('[perf] app_event_service_get_events_collection_emit_skipped', { + durationMs: Number((performance.now() - queryStart).toFixed(2)), + snapshots: querySnapshot?.size || 0, + userID, + reason: 'no_doc_changes', + }); + return; + } + + hasEmitted = true; + emissionCount += 1; + this.logger.log('[perf] app_event_service_get_events_collection_emit', { + durationMs: Number((performance.now() - queryStart).toFixed(2)), + emissionCount, + snapshots: querySnapshot?.size || 0, + userID, + }); + + const eventSnapshots = querySnapshot.docs.map((queryDocumentSnapshot) => ({ + ...(queryDocumentSnapshot.data() as Record), + id: queryDocumentSnapshot.id, + })); + + subscriber.next(eventSnapshots); + }, + (error) => subscriber.error(error) + )); + + return { unsubscribe }; + }); + } + + private async fetchEventsOnceCacheFirst( + q: any, + userID: string, + queryStart: number, + warmServer: boolean + ): Promise { + const cacheStart = performance.now(); + try { + const cacheSnapshot = await runInInjectionContext(this.injector, () => getDocsFromCache(q)); + const cacheEvents = this.deserializeEventsFromQueryDocs(cacheSnapshot.docs, userID, 'app_event_service_get_events_once_cache_deserialize', cacheSnapshot.size); + this.logger.info('[perf] app_event_service_get_events_once_cache_first_hit', { + durationMs: Number((performance.now() - cacheStart).toFixed(2)), + snapshots: cacheSnapshot?.size || 0, + fromCache: cacheSnapshot?.metadata?.fromCache, + hasPendingWrites: cacheSnapshot?.metadata?.hasPendingWrites, + userID, + }); + if ((cacheSnapshot?.size || 0) > 0) { + if (warmServer) { + this.warmEventsOnceServerQuery(q, userID); + } + return { + events: cacheEvents, + source: 'cache', + }; + } + this.logger.info('[perf] app_event_service_get_events_once_cache_first_fallback', { + reason: 'empty_cache', + userID, + }); + } catch (error: any) { + this.logger.warn('[perf] app_event_service_get_events_once_cache_first_failed', { + durationMs: Number((performance.now() - cacheStart).toFixed(2)), + userID, + code: error?.code, + message: error?.message, + }); + } + + return this.fetchEventsOnceFromServer(q, userID, queryStart); + } + + private async fetchEventsOnceFromServer(q: any, userID: string, queryStart: number): Promise { + const querySnapshot = await runInInjectionContext(this.injector, () => getDocs(q)); + this.logger.info('[perf] app_event_service_get_events_once_get_docs', { + durationMs: Number((performance.now() - queryStart).toFixed(2)), + snapshots: querySnapshot?.size || 0, + fromCache: querySnapshot?.metadata?.fromCache, + hasPendingWrites: querySnapshot?.metadata?.hasPendingWrites, + userID, + }); + return { + events: this.deserializeEventsFromQueryDocs(querySnapshot.docs, userID, 'app_event_service_get_events_once_deserialize', querySnapshot.size), + source: 'server', + }; + } + + private warmEventsOnceServerQuery(q: any, userID: string): void { + const warmStart = performance.now(); + void runInInjectionContext(this.injector, () => getDocs(q)).then((snapshot) => { + this.logger.info('[perf] app_event_service_get_events_once_warm_server', { + durationMs: Number((performance.now() - warmStart).toFixed(2)), + snapshots: snapshot?.size || 0, + fromCache: snapshot?.metadata?.fromCache, + hasPendingWrites: snapshot?.metadata?.hasPendingWrites, + userID, + }); + }).catch((error: any) => { + this.logger.warn('[perf] app_event_service_get_events_once_warm_server_failed', { + durationMs: Number((performance.now() - warmStart).toFixed(2)), + userID, + code: error?.code, + message: error?.message, + }); + }); + } + + private deserializeEventsFromQueryDocs( + docs: QueryDocumentSnapshot[], + userID: string, + perfEventName: string, + snapshotsCount?: number + ): EventInterface[] { + const deserializeStart = performance.now(); + const events = docs.map((queryDocumentSnapshot) => { + const eventSnapshot = queryDocumentSnapshot.data(); + const { sanitizedJson, unknownTypes } = EventJSONSanitizer.sanitize(eventSnapshot); + if (unknownTypes.length > 0) { + const newUnknownTypes = unknownTypes.filter(type => AppEventService.shouldReportKey( + AppEventService.reportedUnknownTypes, + type, + AppEventService.DEDUPE_TTL_MS, + AppEventService.DEDUPE_UNKNOWN_TYPES_MAX + )); + if (newUnknownTypes.length > 0) { + this.logger.captureMessage('Unknown Data Types in getEventsOnceBy', { extra: { types: newUnknownTypes, eventID: queryDocumentSnapshot.id } }); + } + } + const event = EventImporterJSON.getEventFromJSON(sanitizedJson).setID(queryDocumentSnapshot.id) as AppEventInterface; + + // Hydrate with original file(s) info if present + const rawData = eventSnapshot as any; + if (rawData.originalFiles) { + event.originalFiles = rawData.originalFiles; + } + if (rawData.originalFile) { + event.originalFile = rawData.originalFile; + } + + this.benchmarkAdapter.applyBenchmarkFieldsFromFirestore(event, rawData); + return event; + }); + this.logger.info(`[perf] ${perfEventName}`, { + durationMs: Number((performance.now() - deserializeStart).toFixed(2)), + snapshots: snapshotsCount ?? docs.length, + userID, + }); + return events; + } + private _getEventsAndActivities(user: User, whereClauses: { fieldPath: string | any, opStr: any, value: any }[] = [], orderByField: string = 'startDate', asc: boolean = false, limitCount: number = 10, startAfterDoc?: any, endBeforeDoc?: any): Observable { const q = this.getEventQueryForUser(user, whereClauses, orderByField, asc, limitCount, startAfterDoc, endBeforeDoc); @@ -1072,14 +1431,6 @@ export class AppEventService implements OnDestroy { } */ - private processStreamDocumentSnapshot(streamSnapshot: DocumentSnapshot): StreamInterface { - return EventImporterJSON.getStreamFromJSON(streamSnapshot.data()); - } - - private processStreamQueryDocumentSnapshot(queryDocumentSnapshot: QueryDocumentSnapshot): StreamInterface { - return EventImporterJSON.getStreamFromJSON(queryDocumentSnapshot.data()); - } - // From https://github.com/angular/angularfire2/issues/1400 private async deleteAllDocsFromCollections(collections: CollectionReference[]) { let totalDeleteCount = 0; diff --git a/src/app/services/app.icon.service.ts b/src/app/services/app.icon.service.ts index b5255299d..f5d8045fe 100644 --- a/src/app/services/app.icon.service.ts +++ b/src/app/services/app.icon.service.ts @@ -24,7 +24,6 @@ export class AppIconService { { name: 'facebook_logo', path: 'assets/logos/facebook_logo.svg' }, { name: 'twitter_logo', path: 'assets/logos/twitter_logo.svg' }, { name: 'github_logo', path: 'assets/logos/github_logo.svg' }, - { name: 'antigravity', path: 'assets/logos/antigravity.svg' }, { name: 'epoc', path: 'assets/icons/epoc.svg' }, { name: 'paypal', path: 'assets/icons/paypal.svg' }, diff --git a/src/app/services/app.original-file-hydration.service.spec.ts b/src/app/services/app.original-file-hydration.service.spec.ts new file mode 100644 index 000000000..4366b81a1 --- /dev/null +++ b/src/app/services/app.original-file-hydration.service.spec.ts @@ -0,0 +1,364 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Storage } from '@angular/fire/storage'; +import { AppOriginalFileHydrationService } from './app.original-file-hydration.service'; +import { AppFileService } from './app.file.service'; +import { LoggerService } from './logger.service'; +import { AppEventUtilities } from '../utils/app.event.utilities'; +import { AppCacheService } from './app.cache.service'; +import { + EventImporterFIT, + EventImporterGPX, + EventImporterSuuntoJSON, + EventImporterSuuntoSML, + EventImporterTCX, + EventUtilities +} from '@sports-alliance/sports-lib'; + +const storageMocks = vi.hoisted(() => ({ + ref: vi.fn(), + getMetadata: vi.fn(), + getBytes: vi.fn(), +})); + +vi.mock('@angular/fire/storage', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ref: storageMocks.ref, + getMetadata: storageMocks.getMetadata, + getBytes: storageMocks.getBytes, + }; +}); + +describe('AppOriginalFileHydrationService', () => { + let service: AppOriginalFileHydrationService; + let fileServiceMock: any; + let eventUtilitiesMock: any; + let cacheServiceMock: any; + let loggerMock: any; + + beforeEach(() => { + fileServiceMock = { + decompressIfNeeded: vi.fn(async (buffer: ArrayBuffer) => buffer), + }; + eventUtilitiesMock = { + enrich: vi.fn(), + }; + cacheServiceMock = { + getFile: vi.fn(), + setFile: vi.fn(), + }; + loggerMock = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + storageMocks.ref.mockReturnValue({}); + storageMocks.getMetadata.mockResolvedValue({ generation: 'gen-1' }); + storageMocks.getBytes.mockResolvedValue(new ArrayBuffer(8)); + + TestBed.configureTestingModule({ + providers: [ + AppOriginalFileHydrationService, + { provide: Storage, useValue: {} }, + { provide: AppFileService, useValue: fileServiceMock }, + { provide: LoggerService, useValue: loggerMock }, + { provide: AppEventUtilities, useValue: eventUtilitiesMock }, + { provide: AppCacheService, useValue: cacheServiceMock } + ] + }); + + service = TestBed.inject(AppOriginalFileHydrationService); + }); + + it('should parse single-file events successfully', async () => { + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: () => [], + } as any; + const parsedEvent = { + getActivities: () => [], + } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile').mockResolvedValue({ event: parsedEvent }); + + const result = await service.parseEventFromOriginalFiles(event, { + preserveActivityIdsFromEvent: false, + }); + + expect(result.finalEvent).toBe(parsedEvent); + expect(result.parsedEvents).toEqual([parsedEvent]); + expect(result.failedFiles).toEqual([]); + expect(result.sourceFilesCount).toBe(1); + }); + + it('should preserve activity IDs and creator overrides by default', async () => { + const parsedActivity = { + setID: vi.fn(), + creator: { name: 'Parser name' }, + }; + const existingActivity = { + getID: () => 'existing-activity-id', + creator: { name: 'User renamed device' }, + }; + const event = { + originalFile: { path: 'users/u/events/e/original.fit' }, + getActivities: () => [existingActivity], + } as any; + const parsedEvent = { + getActivities: () => [parsedActivity], + } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile').mockResolvedValue({ event: parsedEvent }); + + await service.parseEventFromOriginalFiles(event); + + expect(parsedActivity.setID).toHaveBeenCalledWith('existing-activity-id'); + expect(parsedActivity.creator.name).toBe('User renamed device'); + }); + + it('should parse multi-file events and merge parsed results', async () => { + const event = { + originalFiles: [ + { path: 'users/u/events/e/original_0.fit' }, + { path: 'users/u/events/e/original_1.fit' }, + ], + getActivities: () => [], + } as any; + const parsedEvent1 = { getActivities: () => [] } as any; + const parsedEvent2 = { getActivities: () => [] } as any; + const mergedEvent = { getActivities: () => [] } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile') + .mockResolvedValueOnce({ event: parsedEvent1 }) + .mockResolvedValueOnce({ event: parsedEvent2 }); + vi.spyOn(EventUtilities, 'mergeEvents').mockReturnValue(mergedEvent as any); + + const result = await service.parseEventFromOriginalFiles(event, { + preserveActivityIdsFromEvent: false, + }); + + expect(EventUtilities.mergeEvents).toHaveBeenCalledWith([parsedEvent1, parsedEvent2]); + expect(result.finalEvent).toBe(mergedEvent); + expect(result.sourceFilesCount).toBe(2); + expect(result.failedFiles).toEqual([]); + }); + + it('should not merge when mergeMultipleFiles is disabled', async () => { + const event = { + originalFiles: [ + { path: 'users/u/events/e/original_0.fit' }, + { path: 'users/u/events/e/original_1.fit' }, + ], + getActivities: () => [], + } as any; + const parsedEvent1 = { getActivities: () => [] } as any; + const parsedEvent2 = { getActivities: () => [] } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile') + .mockResolvedValueOnce({ event: parsedEvent1 }) + .mockResolvedValueOnce({ event: parsedEvent2 }); + const mergeSpy = vi.spyOn(EventUtilities, 'mergeEvents'); + + const result = await service.parseEventFromOriginalFiles(event, { + preserveActivityIdsFromEvent: false, + mergeMultipleFiles: false, + }); + + expect(mergeSpy).not.toHaveBeenCalled(); + expect(result.finalEvent).toBe(parsedEvent1); + }); + + it('should fail strict parsing when one source file fails', async () => { + const event = { + originalFiles: [ + { path: 'users/u/events/e/original_0.fit' }, + { path: 'users/u/events/e/original_1.fit' }, + ], + getActivities: () => [], + } as any; + const parsedEvent = { getActivities: () => [] } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile') + .mockResolvedValueOnce({ event: parsedEvent }) + .mockResolvedValueOnce({ event: null, reason: 'Parse error' }); + + const result = await service.parseEventFromOriginalFiles(event, { + strictAllFilesRequired: true, + preserveActivityIdsFromEvent: false, + }); + + expect(result.finalEvent).toBeNull(); + expect(result.parsedEvents).toEqual([parsedEvent]); + expect(result.failedFiles).toEqual([ + { path: 'users/u/events/e/original_1.fit', reason: 'Parse error' }, + ]); + }); + + it('should return empty finalEvent when all source files fail in non-strict mode', async () => { + const event = { + originalFiles: [ + { path: 'users/u/events/e/original_0.fit' }, + { path: 'users/u/events/e/original_1.fit' }, + ], + getActivities: () => [], + } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile') + .mockResolvedValueOnce({ event: null, reason: 'first failed' }) + .mockResolvedValueOnce({ event: null, reason: 'second failed' }); + + const result = await service.parseEventFromOriginalFiles(event, { + strictAllFilesRequired: false, + preserveActivityIdsFromEvent: false, + }); + + expect(result.finalEvent).toBeNull(); + expect(result.parsedEvents).toEqual([]); + expect(result.failedFiles).toHaveLength(2); + }); + + it('should normalize .gz extension paths correctly', () => { + const extension = (service as any).getNormalizedExtensionFromPath('users/u/events/e/original.fit.gz'); + expect(extension).toBe('fit'); + }); + + it('should return empty extension when none exists', () => { + const extension = (service as any).getNormalizedExtensionFromPath('users/u/events/e/original'); + expect(extension).toBe('users/u/events/e/original'); + }); + + it('should return source file from legacy originalFile metadata', async () => { + const parsedEvent = { getActivities: () => [] } as any; + vi.spyOn(service as any, 'fetchAndParseOneFile').mockResolvedValue({ event: parsedEvent }); + const event = { + originalFile: { path: 'legacy.fit' }, + getActivities: () => [], + } as any; + + const result = await service.parseEventFromOriginalFiles(event, { + preserveActivityIdsFromEvent: false, + }); + + expect(result.sourceFilesCount).toBe(1); + expect(result.finalEvent).toBe(parsedEvent); + }); + + it('should return empty source list when no metadata exists', async () => { + const event = { + getActivities: () => [], + } as any; + + const result = await service.parseEventFromOriginalFiles(event, { + preserveActivityIdsFromEvent: false, + }); + + expect(result.sourceFilesCount).toBe(0); + expect(result.finalEvent).toBeNull(); + }); + + it('downloadFile should return cached buffer when generation matches', async () => { + const cachedBuffer = new ArrayBuffer(16); + cacheServiceMock.getFile.mockResolvedValue({ buffer: cachedBuffer, generation: 'gen-1' }); + + const result = await service.downloadFile('users/u/events/e/original.fit'); + + expect(storageMocks.getMetadata).toHaveBeenCalled(); + expect(storageMocks.getBytes).not.toHaveBeenCalled(); + expect(result).toBe(cachedBuffer); + }); + + it('downloadFile should download and cache buffer when cache is missing', async () => { + const downloadedBuffer = new ArrayBuffer(20); + cacheServiceMock.getFile.mockResolvedValue(undefined); + storageMocks.getBytes.mockResolvedValue(downloadedBuffer); + + const result = await service.downloadFile('users/u/events/e/original.fit'); + + expect(storageMocks.getBytes).toHaveBeenCalled(); + expect(cacheServiceMock.setFile).toHaveBeenCalledWith('users/u/events/e/original.fit', { + buffer: downloadedBuffer, + generation: 'gen-1', + }); + expect(result).toBe(downloadedBuffer); + }); + + it('downloadFile should fallback to direct download on metadata/cache error', async () => { + const fallbackBuffer = new ArrayBuffer(24); + storageMocks.getMetadata.mockRejectedValue(new Error('metadata failed')); + storageMocks.getBytes.mockResolvedValue(fallbackBuffer); + + const result = await service.downloadFile('users/u/events/e/original.fit'); + + expect(storageMocks.getBytes).toHaveBeenCalled(); + expect(result).toBe(fallbackBuffer); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + it('should parse FIT files and apply enrichment', async () => { + const activity = {}; + const parsedEvent = { getActivities: () => [activity] } as any; + vi.spyOn(service, 'downloadFile').mockResolvedValue(new ArrayBuffer(8)); + vi.spyOn(EventImporterFIT, 'getFromArrayBuffer').mockResolvedValue(parsedEvent as any); + eventUtilitiesMock.enrich.mockImplementation(() => undefined); + + const result = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.fit' }, false); + + expect(EventImporterFIT.getFromArrayBuffer).toHaveBeenCalled(); + expect(eventUtilitiesMock.enrich).toHaveBeenCalledWith(activity, ['Time', 'Duration']); + expect(result.event).toBe(parsedEvent); + }); + + it('should parse GPX/TCX/JSON/SML extensions', async () => { + const parsedEvent = { getActivities: () => [] } as any; + vi.spyOn(service, 'downloadFile').mockResolvedValue(new TextEncoder().encode('').buffer as ArrayBuffer); + vi.spyOn(EventImporterGPX, 'getFromString').mockResolvedValue(parsedEvent as any); + vi.spyOn(EventImporterTCX, 'getFromXML').mockResolvedValue(parsedEvent as any); + vi.spyOn(EventImporterSuuntoJSON, 'getFromJSONString').mockResolvedValue(parsedEvent as any); + vi.spyOn(EventImporterSuuntoSML, 'getFromXML').mockResolvedValue(parsedEvent as any); + + const gpxResult = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.gpx' }, true); + const tcxResult = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.tcx' }, true); + const jsonData = new TextEncoder().encode('{"foo":"bar"}').buffer as ArrayBuffer; + vi.spyOn(service, 'downloadFile').mockResolvedValueOnce(jsonData); + const jsonResult = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.json' }, true); + const smlResult = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.sml' }, true); + + expect(gpxResult.event).toBe(parsedEvent); + expect(tcxResult.event).toBe(parsedEvent); + expect(jsonResult.event).toBe(parsedEvent); + expect(smlResult.event).toBe(parsedEvent); + }); + + it('should warn and continue on duplicate stream enrichment errors', async () => { + const activity = {}; + const parsedEvent = { getActivities: () => [activity] } as any; + vi.spyOn(service, 'downloadFile').mockResolvedValue(new ArrayBuffer(8)); + vi.spyOn(EventImporterFIT, 'getFromArrayBuffer').mockResolvedValue(parsedEvent as any); + eventUtilitiesMock.enrich.mockImplementation(() => { + throw new Error('Duplicate type of stream'); + }); + + const result = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.fit' }, false); + + expect(result.event).toBe(parsedEvent); + expect(loggerMock.warn).toHaveBeenCalled(); + }); + + it('should return parse failure when enrichment throws non-duplicate error', async () => { + const activity = {}; + const parsedEvent = { getActivities: () => [activity] } as any; + vi.spyOn(service, 'downloadFile').mockResolvedValue(new ArrayBuffer(8)); + vi.spyOn(EventImporterFIT, 'getFromArrayBuffer').mockResolvedValue(parsedEvent as any); + eventUtilitiesMock.enrich.mockImplementation(() => { + throw new Error('hard fail'); + }); + + const result = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.fit' }, false); + + expect(result.event).toBeNull(); + expect(result.reason).toContain('hard fail'); + }); + + it('should return parse failure for unsupported extension', async () => { + vi.spyOn(service, 'downloadFile').mockResolvedValue(new ArrayBuffer(8)); + const result = await (service as any).fetchAndParseOneFile({ path: 'users/u/events/e/original.xyz' }); + expect(result.event).toBeNull(); + expect(result.reason).toContain('Unsupported original file extension'); + }); +}); diff --git a/src/app/services/app.original-file-hydration.service.ts b/src/app/services/app.original-file-hydration.service.ts new file mode 100644 index 000000000..4c400879d --- /dev/null +++ b/src/app/services/app.original-file-hydration.service.ts @@ -0,0 +1,228 @@ +import { Injectable, Injector, inject, runInInjectionContext } from '@angular/core'; +import { + EventImporterFIT, + EventImporterGPX, + EventImporterSuuntoJSON, + EventImporterSuuntoSML, + EventImporterTCX, + EventInterface, + EventUtilities, +} from '@sports-alliance/sports-lib'; +import { Storage, getBytes, getMetadata, ref } from '@angular/fire/storage'; +import { AppFileService } from './app.file.service'; +import { LoggerService } from './logger.service'; +import { AppEventUtilities } from '../utils/app.event.utilities'; +import { AppCacheService } from './app.cache.service'; +import { EventJSONSanitizer } from '../utils/event-json-sanitizer'; +import { AppEventInterface, OriginalFileMetaData } from '../../../functions/src/shared/app-event.interface'; +import { createParsingOptions } from '../../../functions/src/shared/parsing-options'; +import { ActivityInterface } from '@sports-alliance/sports-lib'; + +export interface ParseOptions { + skipEnrichment?: boolean; + strictAllFilesRequired?: boolean; + preserveActivityIdsFromEvent?: boolean; + mergeMultipleFiles?: boolean; +} + +export interface ParseFailure { + path: string; + reason: string; +} + +export interface ParseResult { + finalEvent: EventInterface | null; + parsedEvents: EventInterface[]; + sourceFilesCount: number; + failedFiles: ParseFailure[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class AppOriginalFileHydrationService { + private storage = inject(Storage); + private injector = inject(Injector); + private fileService = inject(AppFileService); + private logger = inject(LoggerService); + private appEventUtilities = inject(AppEventUtilities); + private cacheService = inject(AppCacheService); + + public async downloadFile(path: string): Promise { + const fileRef = runInInjectionContext(this.injector, () => ref(this.storage, path)); + + try { + const metadata = await runInInjectionContext(this.injector, () => getMetadata(fileRef)); + const generation = metadata.generation; + const cached = await this.cacheService.getFile(path); + + if (cached && cached.generation === generation) { + this.logger.log(`[AppOriginalFileHydrationService] Cache HIT for ${path}`); + return this.fileService.decompressIfNeeded(cached.buffer, path); + } + + this.logger.log(`[AppOriginalFileHydrationService] Cache MISS/STALE for ${path} (Cloud Gen: ${generation}, Cached Gen: ${cached?.generation})`); + const buffer = await runInInjectionContext(this.injector, () => getBytes(fileRef)); + await this.cacheService.setFile(path, { buffer, generation }); + return this.fileService.decompressIfNeeded(buffer, path); + } catch (e) { + this.logger.error(`[AppOriginalFileHydrationService] Error downloading/caching file ${path}`, e); + const buffer = await runInInjectionContext(this.injector, () => getBytes(fileRef)); + return this.fileService.decompressIfNeeded(buffer, path); + } + } + + public async parseEventFromOriginalFiles(event: AppEventInterface, options: ParseOptions = {}): Promise { + const strictAllFilesRequired = options.strictAllFilesRequired === true; + const preserveActivityIdsFromEvent = options.preserveActivityIdsFromEvent !== false; + const mergeMultipleFiles = options.mergeMultipleFiles !== false; + const sourceFiles = this.getSourceFiles(event); + const parsedEvents: EventInterface[] = []; + const failedFiles: ParseFailure[] = []; + + for (const sourceFile of sourceFiles) { + const result = await this.fetchAndParseOneFile(sourceFile, options.skipEnrichment === true); + if (result.event) { + parsedEvents.push(result.event); + } else { + failedFiles.push({ + path: sourceFile.path, + reason: result.reason || 'Unknown parse failure', + }); + } + } + + if (strictAllFilesRequired && failedFiles.length > 0) { + return { + finalEvent: null, + parsedEvents, + sourceFilesCount: sourceFiles.length, + failedFiles, + }; + } + + const validEvents = parsedEvents.filter((parsedEvent) => !!parsedEvent); + if (validEvents.length === 0) { + return { + finalEvent: null, + parsedEvents: [], + sourceFilesCount: sourceFiles.length, + failedFiles, + }; + } + + const finalEvent = (mergeMultipleFiles && validEvents.length > 1) + ? EventUtilities.mergeEvents(validEvents) + : validEvents[0]; + + if (preserveActivityIdsFromEvent) { + this.applyExistingActivityIdentity(event, finalEvent); + } + + return { + finalEvent, + parsedEvents: validEvents, + sourceFilesCount: sourceFiles.length, + failedFiles, + }; + } + + private getSourceFiles(event: AppEventInterface): OriginalFileMetaData[] { + if (event.originalFiles && event.originalFiles.length > 0) { + return event.originalFiles.filter(file => !!file?.path); + } + + if (event.originalFile && event.originalFile.path) { + return [event.originalFile]; + } + + return []; + } + + private applyExistingActivityIdentity(existingEvent: AppEventInterface, parsedEvent: EventInterface): void { + const existingActivities = existingEvent.getActivities(); + parsedEvent.getActivities().forEach((parsedActivity, index) => { + const existingActivity = existingActivities[index]; + if (!existingActivity) { + return; + } + + const existingId = existingActivity.getID(); + if (existingId) { + parsedActivity.setID(existingId); + } + this.applyUserActivityOverrides(existingActivity, parsedActivity); + }); + } + + private applyUserActivityOverrides(existingActivity: ActivityInterface, parsedActivity: ActivityInterface): void { + if (!existingActivity || !parsedActivity) { + return; + } + + const existingCreatorName = `${existingActivity.creator?.name ?? ''}`.trim(); + if (existingCreatorName && parsedActivity.creator) { + parsedActivity.creator.name = existingCreatorName; + } + } + + private async fetchAndParseOneFile( + fileMeta: { path: string; bucket?: string }, + skipEnrichment: boolean = false, + ): Promise<{ event: EventInterface | null; reason?: string }> { + try { + const arrayBuffer = await this.downloadFile(fileMeta.path); + const extension = this.getNormalizedExtensionFromPath(fileMeta.path); + const options = createParsingOptions(); + let newEvent: EventInterface; + + if (extension === 'fit') { + newEvent = await EventImporterFIT.getFromArrayBuffer(arrayBuffer, options); + } else if (extension === 'gpx') { + const text = new TextDecoder().decode(arrayBuffer); + newEvent = await EventImporterGPX.getFromString(text, null, options); + } else if (extension === 'tcx') { + const text = new TextDecoder().decode(arrayBuffer); + newEvent = await EventImporterTCX.getFromXML((new DOMParser()).parseFromString(text, 'application/xml'), options); + } else if (extension === 'json') { + const text = new TextDecoder().decode(arrayBuffer); + const json = JSON.parse(text); + const { sanitizedJson } = EventJSONSanitizer.sanitize(json); + newEvent = await EventImporterSuuntoJSON.getFromJSONString(JSON.stringify(sanitizedJson)); + } else if (extension === 'sml') { + const text = new TextDecoder().decode(arrayBuffer); + newEvent = await EventImporterSuuntoSML.getFromXML(text); + } else { + return { event: null, reason: `Unsupported original file extension: ${extension}` }; + } + + if (!skipEnrichment) { + newEvent.getActivities().forEach(activity => { + try { + this.appEventUtilities.enrich(activity, ['Time', 'Duration']); + } catch (e) { + if ((e as Error)?.message?.includes('Duplicate type of stream')) { + this.logger.warn('[AppOriginalFileHydrationService] Duplicate stream warning during enrichment', e); + } else { + throw e; + } + } + }); + } + + return { event: newEvent }; + } catch (e) { + this.logger.error('[AppOriginalFileHydrationService] Error parsing original file', fileMeta?.path, e); + return { event: null, reason: (e as Error)?.message || 'Could not parse file' }; + } + } + + private getNormalizedExtensionFromPath(path: string): string { + const parts = path.split('.'); + let extension = parts.pop()?.toLowerCase(); + if (extension === 'gz') { + extension = parts.pop()?.toLowerCase(); + } + return extension || ''; + } +} diff --git a/src/app/services/app.payment.service.spec.ts b/src/app/services/app.payment.service.spec.ts index 9ea1e23c8..f325e9c3d 100644 --- a/src/app/services/app.payment.service.spec.ts +++ b/src/app/services/app.payment.service.spec.ts @@ -28,6 +28,9 @@ vi.mock('@angular/fire/functions', async () => { const { mockAddDoc, + mockGetDoc, + mockGetDocs, + mockLimit, mockDocData, mockCollection, mockDoc, @@ -38,6 +41,9 @@ const { } = vi.hoisted(() => { return { mockAddDoc: vi.fn(), + mockGetDoc: vi.fn(), + mockGetDocs: vi.fn(), + mockLimit: vi.fn(), mockDocData: vi.fn(), mockCollection: vi.fn(), mockDoc: vi.fn(), @@ -54,6 +60,9 @@ vi.mock('@angular/fire/firestore', async () => { return { ...actual, addDoc: mockAddDoc, + getDoc: mockGetDoc, + getDocs: mockGetDocs, + limit: mockLimit, collection: mockCollection, doc: mockDoc, docData: mockDocData, @@ -88,10 +97,23 @@ describe('AppPaymentService', () => { beforeEach(() => { vi.clearAllMocks(); // Reset spies + mockAuth.currentUser = { + uid: 'test_user_uid', + getIdToken: vi.fn() + }; + mockFunctionsService.call.mockReset(); + mockFunctionsService.call.mockResolvedValue({ data: {} }); + // Configure mock implementations here where 'of' is available mockAddDoc.mockResolvedValue({ id: 'test_session_id' }); mockDocData.mockReturnValue(of({ url: 'http://stripe.com/checkout' })); mockCollectionData.mockReturnValue(of([])); + mockGetDocs.mockResolvedValue({ docs: [] }); + mockGetDoc.mockResolvedValue({ + exists: () => false, + data: () => undefined + }); + mockLimit.mockImplementation((value: number) => value); mockRunInInjectionContext.mockImplementation((injector: any, fn: any) => fn()); TestBed.configureTestingModule({ @@ -211,7 +233,7 @@ describe('AppPaymentService', () => { unit_amount: 1000, description: 'Monthly with invalid promo', metadata: { - promotion_code_id: 'TRAIL_START' + promotion_code_id: 'SAVE10' } } as any; @@ -224,6 +246,178 @@ describe('AppPaymentService', () => { expect(payload.promotion_code).toBeUndefined(); expect(payload.allow_promotion_codes).toBe(true); }); + + it('should ignore legacy promotion code metadata keys and keep manual promotion codes enabled', async () => { + const recurringPriceWithLegacyPromoKey = { + id: 'price_recurring_legacy_promo', + type: 'recurring', + active: true, + currency: 'usd', + unit_amount: 1000, + description: 'Monthly with legacy promo key', + metadata: { + promotionCodeId: 'promo_111111111' + } + } as any; + + await service.appendCheckoutSession(recurringPriceWithLegacyPromoKey); + + expect(mockAddDoc).toHaveBeenCalled(); + const args = mockAddDoc.mock.calls[0]; + const payload = args[1]; + + expect(payload.promotion_code).toBeUndefined(); + expect(payload.allow_promotion_codes).toBe(true); + }); + + it('should apply promotion code from stripe_metadata_promotion_code_id fallback field', async () => { + const recurringPriceWithPrefixedPromo = { + id: 'price_recurring_prefixed_promo', + type: 'recurring', + active: true, + currency: 'usd', + unit_amount: 1000, + description: 'Monthly with prefixed promo metadata', + stripe_metadata_promotion_code_id: 'promo_987654321' + } as any; + + await service.appendCheckoutSession(recurringPriceWithPrefixedPromo); + + expect(mockAddDoc).toHaveBeenCalled(); + const args = mockAddDoc.mock.calls[0]; + const payload = args[1]; + + expect(payload.promotion_code).toBe('promo_987654321'); + expect(payload.allow_promotion_codes).toBe(false); + }); + + it('should restore and short-circuit checkout when an existing subscription is linked', async () => { + mockFunctionsService.call.mockImplementation(async (functionName: string) => { + if (functionName === 'linkExistingStripeCustomer') { + return { data: { linked: true, role: 'pro' } }; + } + return { data: {} }; + }); + + await expect(service.appendCheckoutSession('price_123')).rejects.toThrow('SUBSCRIPTION_RESTORED:pro'); + expect(mockAuth.currentUser.getIdToken).toHaveBeenCalledWith(true); + expect(mockAddDoc).not.toHaveBeenCalled(); + }); + + it('should exit checkout when user cancels manage-subscription prompt', async () => { + const dialog = TestBed.inject(MatDialog); + vi.spyOn(dialog, 'open').mockReturnValue({ + afterClosed: () => of(false) + } as any); + mockCollectionData.mockReturnValueOnce(of([{ status: 'active' }])); + + await service.appendCheckoutSession('price_123'); + + expect(mockAddDoc).not.toHaveBeenCalled(); + }); + + it('should hand off to manageSubscriptions when user confirms existing-subscription prompt', async () => { + const dialog = TestBed.inject(MatDialog); + vi.spyOn(dialog, 'open').mockReturnValue({ + afterClosed: () => of(true) + } as any); + const manageSpy = vi.spyOn(service, 'manageSubscriptions').mockResolvedValue(); + mockCollectionData.mockReturnValueOnce(of([{ status: 'active' }])); + + await service.appendCheckoutSession('price_123'); + + expect(manageSpy).toHaveBeenCalledTimes(1); + expect(mockAddDoc).not.toHaveBeenCalled(); + }); + + it('should retry checkout once after stale customer error and then continue', async () => { + mockDocData + .mockReturnValueOnce(of({ error: { message: 'No such customer: cus_123' } })) + .mockReturnValueOnce(of({ url: 'http://stripe.com/checkout' })); + + await service.appendCheckoutSession('price_123'); + + expect(mockAddDoc).toHaveBeenCalledTimes(2); + const cleanupCalls = mockFunctionsService.call.mock.calls.filter(call => call[0] === 'cleanupStripeCustomer'); + expect(cleanupCalls).toHaveLength(1); + }); + + it('should stop retrying after max retry attempts for stale customer errors', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + mockDocData + .mockReturnValueOnce(of({ error: { message: 'No such customer: cus_123' } })) + .mockReturnValueOnce(of({ error: { message: 'No such customer: cus_456' } })); + + await service.appendCheckoutSession('price_123'); + + expect(mockAddDoc).toHaveBeenCalledTimes(2); + const cleanupCalls = mockFunctionsService.call.mock.calls.filter(call => call[0] === 'cleanupStripeCustomer'); + expect(cleanupCalls).toHaveLength(1); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('No such customer')); + alertSpy.mockRestore(); + }); + + it('should apply promotion code from Firestore price document metadata fallback', async () => { + const recurringPriceWithoutPromoMetadata = { + id: 'price_firestore_fallback', + product: 'prod_123', + type: 'recurring', + active: true, + currency: 'usd', + unit_amount: 1000, + description: 'Monthly without inline promo metadata', + metadata: {} + } as any; + + mockGetDoc.mockResolvedValueOnce({ + exists: () => true, + data: () => ({ + metadata: { + promotion_code_id: 'promo_firestore_123' + } + }) + }); + + await service.appendCheckoutSession(recurringPriceWithoutPromoMetadata); + + expect(mockAddDoc).toHaveBeenCalled(); + const args = mockAddDoc.mock.calls[0]; + const payload = args[1]; + expect(payload.promotion_code).toBe('promo_firestore_123'); + expect(payload.allow_promotion_codes).toBe(false); + expect(mockGetDocs).not.toHaveBeenCalled(); + }); + + it('should apply promotion code from active-product scan when product ID is not present on price object', async () => { + const recurringPriceWithoutProduct = { + id: 'price_firestore_scan_fallback', + type: 'recurring', + active: true, + currency: 'usd', + unit_amount: 1000, + description: 'Monthly without product', + metadata: {} + } as any; + + mockGetDocs.mockResolvedValueOnce({ + docs: [{ id: 'prod_from_scan' }] + }); + mockGetDoc.mockResolvedValueOnce({ + exists: () => true, + data: () => ({ + stripe_metadata_promotion_code_id: 'promo_firestore_scan' + }) + }); + + await service.appendCheckoutSession(recurringPriceWithoutProduct); + + expect(mockAddDoc).toHaveBeenCalled(); + const args = mockAddDoc.mock.calls[0]; + const payload = args[1]; + expect(payload.promotion_code).toBe('promo_firestore_scan'); + expect(payload.allow_promotion_codes).toBe(false); + expect(mockGetDocs).toHaveBeenCalledTimes(1); + }); }); describe('restorePurchases', () => { it('should return the role from the cloud function response', async () => { @@ -236,4 +430,35 @@ describe('AppPaymentService', () => { expect(mockFunctionsService.call).toHaveBeenCalledWith('restoreUserClaims'); }); }); + + describe('hasPaidSubscriptionHistory', () => { + it('should return false when there is no authenticated user', async () => { + mockAuth.currentUser = null; + + const hasHistory = await service.hasPaidSubscriptionHistory(); + + expect(hasHistory).toBe(false); + expect(mockGetDocs).not.toHaveBeenCalled(); + }); + + it('should return true when at least one subscription document exists', async () => { + mockGetDocs.mockResolvedValueOnce({ + docs: [{ id: 'sub_123' }] + }); + + const hasHistory = await service.hasPaidSubscriptionHistory(); + + expect(hasHistory).toBe(true); + expect(mockGetDocs).toHaveBeenCalledTimes(1); + expect(mockLimit).toHaveBeenCalledWith(1); + }); + + it('should return true when the history query fails (fail-closed for trial messaging)', async () => { + mockGetDocs.mockRejectedValueOnce(new Error('Firestore unavailable')); + + const hasHistory = await service.hasPaidSubscriptionHistory(); + + expect(hasHistory).toBe(true); + }); + }); }); diff --git a/src/app/services/app.payment.service.ts b/src/app/services/app.payment.service.ts index a4004adef..76125c35d 100644 --- a/src/app/services/app.payment.service.ts +++ b/src/app/services/app.payment.service.ts @@ -2,13 +2,13 @@ import { Injectable, inject, Injector, runInInjectionContext } from '@angular/co import { MatDialog } from '@angular/material/dialog'; import { ConfirmationDialogComponent } from '../components/confirmation-dialog/confirmation-dialog.component'; import { environment } from '../../environments/environment'; -import { Firestore, collection, collectionData, addDoc, doc, docData, query, where } from '@angular/fire/firestore'; +import { Firestore, collection, collectionData, addDoc, doc, docData, getDoc, getDocs, limit, query, where } from '@angular/fire/firestore'; // ... (other imports) import { Auth } from '@angular/fire/auth'; -import { Observable, from, switchMap, filter, take, map, timeout } from 'rxjs'; +import { Observable, from, switchMap, filter, take, map, timeout, firstValueFrom } from 'rxjs'; import { AppWindowService } from './app.window.service'; import { LoggerService } from './logger.service'; import { AppFunctionsService } from './app.functions.service'; @@ -35,6 +35,8 @@ export interface StripePrice { interval_count: number | null; trial_period_days: number | null; metadata?: { [key: string]: string }; + product?: string; + stripe_metadata_promotion_code_id?: string; recurring?: { interval: 'day' | 'month' | 'week' | 'year'; interval_count?: number; @@ -49,6 +51,31 @@ export interface StripeSubscription { cancel_at_period_end: boolean; } +type CheckoutMode = 'subscription' | 'payment'; + +interface CheckoutInput { + priceId: string; + mode: CheckoutMode; + productId: string | null; +} + +interface CheckoutSessionPayload { + price: string; + success_url: string; + cancel_url: string; + allow_promotion_codes: boolean; + mode: CheckoutMode; + automatic_tax: { enabled: true }; + metadata: { firebaseUID: string }; + promotion_code?: string; + subscription_data?: { metadata: { firebaseUID: string } }; +} + +interface CheckoutSessionDocumentData { + url?: string; + error?: string | { message?: string }; +} + @Injectable({ providedIn: 'root' }) @@ -58,6 +85,9 @@ export class AppPaymentService { private functionsService = inject(AppFunctionsService); private dialog = inject(MatDialog); private injector = inject(Injector); + private readonly userCancelledPortalMessage = 'User cancelled redirection to portal.'; + private readonly maxCheckoutRetryAttempts = 1; + private readonly subscriptionStatuses: StripeSubscription['status'][] = ['active', 'trialing', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid']; constructor(private windowService: AppWindowService, private logger: LoggerService) { } @@ -154,11 +184,11 @@ export class AppPaymentService { const pricesRef = collection(this.firestore, `products/${product.id}/prices`); const activePricesQuery = query(pricesRef, where('active', '==', true)); - return new Promise((resolve) => { - runInInjectionContext(this.injector, () => collectionData(activePricesQuery, { idField: 'id' })).pipe(take(1)).subscribe((prices) => { - resolve({ ...product, prices: prices as StripePrice[] }); - }); - }); + const prices = await firstValueFrom( + runInInjectionContext(this.injector, () => collectionData(activePricesQuery, { idField: 'id' })).pipe(take(1)) + ); + + return { ...product, prices: prices as StripePrice[] }; } /** @@ -170,199 +200,298 @@ export class AppPaymentService { throw new Error('User must be authenticated to create a checkout session.'); } - // Determine priceId and mode - const priceId = typeof price === 'string' ? price : price.id; - // Default to subscription if string passed or recurring field present, otherwise payment - const mode = typeof price === 'string' ? 'subscription' : (price.type === 'recurring' ? 'subscription' : 'payment'); - const promotionCodeId = this.resolvePromotionCodeId(price); + const success = successUrl || `${this.windowService.currentDomain}/payment/success`; + const cancel = cancelUrl || `${this.windowService.currentDomain}/payment/cancel`; + await this.appendCheckoutSessionWithAttempt(price, user.uid, success, cancel, user, 0); + } + + private async appendCheckoutSessionWithAttempt( + price: string | StripePrice, + userId: string, + successUrl: string, + cancelUrl: string, + user: { getIdToken: (forceRefresh?: boolean) => Promise }, + attempt: number + ): Promise { + const checkoutInput = this.resolveCheckoutInput(price); + const promotionCodeId = await this.resolvePromotionCodeIdForCheckout(price, checkoutInput); + + await this.runPreCheckoutLinkCheck(user); + + const shouldExitCheckout = await this.handleExistingActiveSubscriptions(userId); + if (shouldExitCheckout) { + return; + } - // Pre-checkout check: Link existing Stripe customer if found - // This prevents duplicate subscriptions for recreated users + this.logger.log('Creating checkout session for price:', checkoutInput.priceId, 'mode:', checkoutInput.mode); + const checkoutSessionsRef = collection(this.firestore, `customers/${userId}/checkout_sessions`); + const sessionPayload = this.buildCheckoutSessionPayload(checkoutInput, userId, successUrl, cancelUrl, promotionCodeId); + + let checkoutSessionDocId = ''; try { - const result = await this.functionsService.call('linkExistingStripeCustomer'); + const sessionDoc = await runInInjectionContext(this.injector, () => addDoc(checkoutSessionsRef, sessionPayload)); + checkoutSessionDocId = sessionDoc.id; + this.logger.log('Checkout session created with ID:', checkoutSessionDocId); + } catch (error) { + this.logger.error('Error creating checkout doc:', error); + throw error; + } - if (result.data.linked) { - this.logger.log(`Existing subscription found and linked. Role: ${result.data.role}. Skipping checkout.`); - // Force token refresh to pick up new claims - await user.getIdToken(true); - // Throw a specific error to signal the calling code that we linked instead of checking out - throw new Error(`SUBSCRIPTION_RESTORED:${result.data.role}`); + let session: CheckoutSessionDocumentData; + try { + session = await this.waitForCheckoutSessionUpdate(userId, checkoutSessionDocId); + } catch (error) { + this.logger.error('Error waiting for checkout session URL:', error); + const errorName = error instanceof Error ? error.name : ''; + if (errorName === 'TimeoutError') { + alert('Payment system is slow to respond. Please check if the popup was blocked or try again.'); + } else { + alert('An error occurred starting the payment. Please try again.'); } - } catch (error: unknown) { - const err = error as Error; - if (err.message?.startsWith('SUBSCRIPTION_RESTORED:')) { - throw error; // Re-throw so the caller can handle it + return; + } + + if (session.error) { + const errorMessage = this.getCheckoutErrorMessage(session.error); + this.logger.error('Stripe extension returned an error:', session.error); + + if (errorMessage.includes('No such customer') && attempt < this.maxCheckoutRetryAttempts) { + this.logger.log('Detected stale Stripe customer ID. Clearing and retrying...'); + await this.functionsService.call('cleanupStripeCustomer'); + await this.appendCheckoutSessionWithAttempt(price, userId, successUrl, cancelUrl, user, attempt + 1); + return; } - this.logger.warn('Pre-checkout link check failed, proceeding with checkout:', error); - // Continue with checkout if the linking check fails + + alert(`Payment error: ${errorMessage}`); + return; } - // Check for existing active subscriptions first (only relevant for subscriptions) - // If it's a one-time payment mode, we might not need to block? - // But let's keep logic simple: manage sub if active sub exists. + if (!session.url) { + alert('An error occurred starting the payment. Please try again.'); + return; + } - const subscriptionsRef = collection(this.firestore, `customers/${user.uid}/subscriptions`); - const activeQuery = query(subscriptionsRef, where('status', 'in', ['active', 'trialing'])); + this.logger.log('Redirecting to Stripe:', session.url); + window.location.assign(session.url); + } - try { - const snapshot = from(runInInjectionContext(this.injector, () => collectionData(activeQuery).pipe(take(1)))); - const activeSubs = await snapshot.toPromise(); - - // Only block/prompt if we are trying to start a NEW subscription while one exists - // One-time payments (mode === 'payment') can probably proceed? - // User requirement: "I made my free product one time paid" -> imply upgrading/buying. - // Let's assume if mode is payment, we allow it (e.g. lifetime), or still warn? - // Safer to warn if they have *any* active subscription to avoid confusion, - // but stricly speaking a one-time purchase is separate. - // Let's stick to existing logic for now but perhaps skip if mode is payment? - // For now, keep as is. - - if (activeSubs && activeSubs.length > 0) { - // ... existing dialog logic ... - // (rest of the block is unchanged, just omitted for brevity in diff if not touching it) - // actually I need to include it or carefully slice. - // let me just allow the logic to run for now. - this.logger.warn('User already has an active subscription. Opening management dialog.'); - - const dialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - title: 'Active Subscription', - message: 'You already have an active subscription. Would you like to manage it instead?', - confirmText: 'Manage Subscription', - cancelText: 'Cancel' - } - }); + private resolveCheckoutInput(price: string | StripePrice): CheckoutInput { + if (typeof price === 'string') { + return { + priceId: price, + mode: 'subscription', + productId: null + }; + } - const confirmed = await new Promise(resolve => { - dialogRef.afterClosed().pipe(take(1)).subscribe(result => resolve(!!result)); - }); + return { + priceId: price.id, + mode: price.type === 'recurring' ? 'subscription' : 'payment', + productId: price.product ?? null + }; + } - if (confirmed) { - await this.manageSubscriptions(); - return; // Successfully handed off to manage portal - } else { - // Explicitly throw a known error for cancellation so UI can stop loading - throw new Error('User cancelled redirection to portal.'); - } - } - } catch (e: any) { - if (e.message === 'User cancelled redirection to portal.') { + private async resolvePromotionCodeIdForCheckout( + price: string | StripePrice, + checkoutInput: CheckoutInput + ): Promise { + const promotionCodeId = this.resolvePromotionCodeId(price); + if (promotionCodeId || typeof price === 'string') { + return promotionCodeId; + } + + return this.resolvePromotionCodeIdFromFirestoreDocument(checkoutInput.priceId, checkoutInput.productId); + } + + private async runPreCheckoutLinkCheck(user: { getIdToken: (forceRefresh?: boolean) => Promise }): Promise { + try { + const result = await this.functionsService.call('linkExistingStripeCustomer'); + if (!result.data.linked) { return; } - this.logger.error('Error checking existing subscriptions:', e); - } - const success = successUrl || `${this.windowService.currentDomain}/payment/success`; - const cancel = cancelUrl || `${this.windowService.currentDomain}/payment/cancel`; + this.logger.log(`Existing subscription found and linked. Role: ${result.data.role}. Skipping checkout.`); + await user.getIdToken(true); + throw new Error(`SUBSCRIPTION_RESTORED:${result.data.role}`); + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith('SUBSCRIPTION_RESTORED:')) { + throw error; + } + this.logger.warn('Pre-checkout link check failed, proceeding with checkout:', error); + } + } - this.logger.log('Creating checkout session for price:', priceId, 'mode:', mode); - const checkoutSessionsRef = collection(this.firestore, `customers/${user.uid}/checkout_sessions`); + private async handleExistingActiveSubscriptions(userId: string): Promise { + const subscriptionsRef = collection(this.firestore, `customers/${userId}/subscriptions`); + const activeQuery = query(subscriptionsRef, where('status', 'in', ['active', 'trialing'])); try { - // CRITICAL FIX: Explicitly add metadata to the session and subscription_data. - // The `firestore-stripe-payments` extension relies on finding `firebaseUID` in the Stripe object's metadata - // to map the payment/invoice back to the correct Firestore User Document. - // - // Without this, the extension fails with: 'Value for argument "documentPath" is not a valid resource path' - // because it receives an empty UID when processing webhooks (like invoice.paid). - // - // Stripe DOES NOT automatically propagate Customer metadata to Invoices/Subscriptions. - // We must force this propagation here during Checkout Session creation. - const sessionPayload: any = { - price: priceId, - success_url: success, - cancel_url: cancel, - allow_promotion_codes: !promotionCodeId, - mode: mode, // Explicitly set mode - automatic_tax: { enabled: true }, - metadata: { - firebaseUID: user.uid + const activeSubs = await firstValueFrom( + runInInjectionContext(this.injector, () => collectionData(activeQuery).pipe(take(1))) + ); + + if (!activeSubs.length) { + return false; + } + + this.logger.warn('User already has an active subscription. Opening management dialog.'); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Active Subscription', + message: 'You already have an active subscription. Would you like to manage it instead?', + confirmText: 'Manage Subscription', + cancelText: 'Cancel' } - }; + }); - if (promotionCodeId) { - sessionPayload.promotion_code = promotionCodeId; + const confirmed = !!(await firstValueFrom(dialogRef.afterClosed().pipe(take(1)))); + if (!confirmed) { + throw new Error(this.userCancelledPortalMessage); } - if (mode === 'subscription') { - // Ensure the metadata is attached to the Subscription object created by this checkout. - // This ensures that future recurring invoices generated from this subscription - // will carry this metadata, allowing the extension to process renewal webhooks correctly. - sessionPayload.subscription_data = { - metadata: { - firebaseUID: user.uid - } - }; + await this.manageSubscriptions(); + return true; + } catch (error) { + if (error instanceof Error && error.message === this.userCancelledPortalMessage) { + return true; } - const sessionDoc = await runInInjectionContext(this.injector, () => addDoc(checkoutSessionsRef, sessionPayload)); + this.logger.error('Error checking existing subscriptions:', error); + return false; + } + } + + private buildCheckoutSessionPayload( + checkoutInput: CheckoutInput, + userId: string, + successUrl: string, + cancelUrl: string, + promotionCodeId: string | null + ): CheckoutSessionPayload { + const payload: CheckoutSessionPayload = { + price: checkoutInput.priceId, + success_url: successUrl, + cancel_url: cancelUrl, + allow_promotion_codes: !promotionCodeId, + mode: checkoutInput.mode, + automatic_tax: { enabled: true }, + metadata: { firebaseUID: userId } + }; + + if (promotionCodeId) { + payload.promotion_code = promotionCodeId; + } - this.logger.log('Checkout session created with ID:', sessionDoc.id); + if (checkoutInput.mode === 'subscription') { + payload.subscription_data = { + metadata: { firebaseUID: userId } + }; + } - // Wait for the extension to add the URL - const sessionRef = doc(this.firestore, `customers/${user.uid}/checkout_sessions/${sessionDoc.id}`); + return payload; + } + private async waitForCheckoutSessionUpdate(userId: string, checkoutSessionDocId: string): Promise { + const sessionRef = doc(this.firestore, `customers/${userId}/checkout_sessions/${checkoutSessionDocId}`); + const session = await firstValueFrom( runInInjectionContext(this.injector, () => docData(sessionRef)).pipe( - filter((session: any) => session?.url || session?.error), + filter((sessionData): sessionData is CheckoutSessionDocumentData => { + const data = sessionData as CheckoutSessionDocumentData | null | undefined; + return !!data && (!!data.url || !!data.error); + }), take(1), - timeout(15000) // Timeout after 15 seconds - ).subscribe({ - next: async (session: any) => { - if (session.error) { - this.logger.error('Stripe extension returned an error:', session.error); + timeout(15000) + ) + ); + return session; + } - // Self-healing: If customer not found, clear IDs and retry once - if (session.error.message?.includes('No such customer')) { - this.logger.log('Detected stale Stripe customer ID. Clearing and retrying...'); + private getCheckoutErrorMessage(error: CheckoutSessionDocumentData['error']): string { + if (!error) { + return 'Unknown payment error.'; + } - await this.functionsService.call('cleanupStripeCustomer'); + if (typeof error === 'string') { + return error; + } - // Retry the specific checkout session creation - return this.appendCheckoutSession(priceId, success, cancel); - } - alert(`Payment error: ${session.error.message}`); - return; - } - this.logger.log('Redirecting to Stripe:', session.url); - window.location.assign(session.url); - }, - error: (err) => { - this.logger.error('Error waiting for checkout session URL:', err); - if (err.name === 'TimeoutError') { - alert('Payment system is slow to respond. Please check if the popup was blocked or try again.'); - } else { - alert('An error occurred starting the payment. Please try again.'); - } - } - }); - } catch (e) { - this.logger.error('Error creating checkout doc:', e); - throw e; + if (error.message && error.message.trim()) { + return error.message; } + + return 'Unknown payment error.'; } private resolvePromotionCodeId(price: string | StripePrice): string | null { - if (typeof price === 'string' || !price.metadata) { + if (typeof price === 'string') { return null; } - const keys = ['promotion_code_id', 'promotionCodeId', 'promotion_code', 'promotionCode']; - for (const key of keys) { - const rawValue = price.metadata[key]; - if (!rawValue) { - continue; - } + const prefixedMetadataValue = price.stripe_metadata_promotion_code_id ?? null; + const strictMetadataValue = price.metadata?.promotion_code_id ?? null; + const rawValue = strictMetadataValue ?? prefixedMetadataValue; + if (!rawValue) { + return null; + } + + const promotionCodeId = rawValue.trim(); + if (!promotionCodeId) { + return null; + } + + if (promotionCodeId.startsWith('promo_')) { + return promotionCodeId; + } + + const sourceKey = strictMetadataValue ? 'promotion_code_id' : 'stripe_metadata_promotion_code_id'; + this.logger.warn(`[appendCheckoutSession] Ignoring metadata '${sourceKey}' because '${promotionCodeId}' is not a Stripe promotion code ID (expected prefix: promo_).`); + return null; + } + + private async resolvePromotionCodeIdFromFirestoreDocument(priceId: string, productId: string | null): Promise { + const candidatePaths: string[] = []; + if (productId) { + candidatePaths.push(`products/${productId}/prices/${priceId}`); + } - const promotionCodeId = rawValue.trim(); - if (!promotionCodeId) { - continue; + try { + if (!candidatePaths.length) { + const productsRef = collection(this.firestore, 'products'); + const activeProductsQuery = query(productsRef, where('active', '==', true)); + const productsSnapshot = await runInInjectionContext(this.injector, () => getDocs(activeProductsQuery)); + for (const productDoc of productsSnapshot.docs) { + candidatePaths.push(`products/${productDoc.id}/prices/${priceId}`); + } } - if (promotionCodeId.startsWith('promo_')) { + for (const path of candidatePaths) { + const priceRef = doc(this.firestore, path); + const priceSnapshot = await runInInjectionContext(this.injector, () => getDoc(priceRef)); + if (!priceSnapshot.exists()) { + continue; + } + + const data = priceSnapshot.data() as { + metadata?: { [key: string]: string }; + stripe_metadata_promotion_code_id?: string; + }; + const strictMetadataValue = data.metadata?.promotion_code_id ?? null; + const prefixedMetadataValue = data.stripe_metadata_promotion_code_id ?? null; + const rawValue = strictMetadataValue ?? prefixedMetadataValue; + + if (!rawValue) { + return null; + } + + const promotionCodeId = rawValue.trim(); + if (!promotionCodeId.startsWith('promo_')) { + return null; + } + return promotionCodeId; } - - this.logger.warn(`[appendCheckoutSession] Ignoring metadata '${key}' because '${promotionCodeId}' is not a Stripe promotion code ID (expected prefix: promo_).`); + } catch { + // If fallback lookup fails, continue without a promotion code. } return null; @@ -415,6 +544,28 @@ export class AppPaymentService { ) as Observable<(StripeSubscription & { role?: string })[]>; } + async hasPaidSubscriptionHistory(): Promise { + const user = this.auth.currentUser; + if (!user) { + return false; + } + + const subscriptionsRef = collection(this.firestore, `customers/${user.uid}/subscriptions`); + const historyQuery = query( + subscriptionsRef, + where('status', 'in', this.subscriptionStatuses), + limit(1) + ); + + try { + const snapshot = await runInInjectionContext(this.injector, () => getDocs(historyQuery)); + return snapshot.docs.length > 0; + } catch (error) { + this.logger.warn('Could not verify subscription history. Hiding trial messaging by default.', error); + return true; + } + } + /** * Opens the Stripe Customer Portal for managing subscriptions. */ diff --git a/src/app/services/app.share.service.spec.ts b/src/app/services/app.share.service.spec.ts index feaf2a513..ef8729a5f 100644 --- a/src/app/services/app.share.service.spec.ts +++ b/src/app/services/app.share.service.spec.ts @@ -74,7 +74,7 @@ describe('AppShareService', () => { await service.shareBenchmarkAsImage(source, { width: 960, watermark: { - brand: 'Quantified Self', + brand: 'My Brand', timestamp: 'Jan 1, 2025', url: 'quantified-self.io', }, @@ -88,8 +88,10 @@ describe('AppShareService', () => { const watermark = capturedElement?.querySelector('.benchmark-watermark'); expect(watermark).toBeTruthy(); + expect(watermark?.querySelector('.watermark-brand-line')?.textContent).toContain('My Brand'); + expect(watermark?.querySelector('.watermark-app-line')?.textContent).toContain('Quantified Self'); expect(watermark?.textContent).toContain('Quantified Self'); - expect(watermark?.textContent).toContain('quantified-self.io'); + expect(watermark?.textContent).not.toContain('quantified-self.io'); }); it('retries with lightweight options when source decode fails', async () => { diff --git a/src/app/services/app.share.service.ts b/src/app/services/app.share.service.ts index 3c14bcf24..508add90b 100644 --- a/src/app/services/app.share.service.ts +++ b/src/app/services/app.share.service.ts @@ -50,12 +50,11 @@ export class AppShareService { const watermark = document.createElement('div'); watermark.className = 'benchmark-watermark'; watermark.innerHTML = ` -
- ${logoUrl ? `` : ''} - ${options.watermark.brand} + ${options.watermark.brand ? `
${options.watermark.brand}
` : ''} +
+ ${logoUrl ? `` : ''} + Quantified Self
- ${options.watermark.url ? `${options.watermark.url}` : ''} - ${options.watermark.timestamp} `; this.applyAngularContentAttr(clone, watermark); clone.appendChild(watermark); @@ -228,13 +227,9 @@ export class AppShareService { if (typeof img.decode === 'function') { try { await img.decode(); - window.clearTimeout(timeoutId); - finish(true); - return; } catch { - window.clearTimeout(timeoutId); - finish(false); - return; + // Some browsers may reject decode() for otherwise renderable images. + // Since onload fired, keep the logo and let render fallback handle failures. } } window.clearTimeout(timeoutId); diff --git a/src/app/services/app.user-settings-query.service.ts b/src/app/services/app.user-settings-query.service.ts index 2eff277ef..d2d57e760 100644 --- a/src/app/services/app.user-settings-query.service.ts +++ b/src/app/services/app.user-settings-query.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; -import { map, distinctUntilChanged, tap } from 'rxjs/operators'; +import { map, distinctUntilChanged } from 'rxjs/operators'; import { AppAuthService } from '../authentication/app.auth.service'; import { AppUserService } from './app.user.service'; import { @@ -72,8 +72,7 @@ export class AppUserSettingsQueryService { public readonly myTracksSettings = toSignal( this.user$.pipe( map(user => user?.settings?.myTracksSettings ?? {} as UserMyTracksSettingsInterface), - distinctUntilChanged((prev, curr) => equal(prev, curr)), - tap(settings => this.logger.info('[AppUserSettingsQueryService] Only Emitting My Tracks Settings Change:', settings)) + distinctUntilChanged((prev, curr) => equal(prev, curr)) ), { initialValue: {} as UserMyTracksSettingsInterface } ); diff --git a/src/app/services/color/app.event.color.service.ts b/src/app/services/color/app.event.color.service.ts index 3a658db35..727b113b6 100644 --- a/src/app/services/color/app.event.color.service.ts +++ b/src/app/services/color/app.event.color.service.ts @@ -117,37 +117,41 @@ export class AppEventColorService { return `linear-gradient(135deg, ${solid}, ${solid})`; } - getColorForZone(zone: string): am4core.Color | null { - // Get the cached core module from the service (it will be loaded when charts are initialized) - const core = this.amChartsService.getCachedCore(); - if (!core) { - this.logger.warn('amCharts core not loaded yet'); - return null; - } - + getColorForZoneHex(zone: string): string { switch (zone) { case `Zone 7`: case `Z7`: - return core.color(AppColors.Purple); + return AppColors.Purple; case `Zone 6`: case `Z6`: - return core.color(AppColors.Red); + return AppColors.Red; case `Zone 5`: case `Z5`: - return core.color(AppColors.LightestRed); + return AppColors.LightestRed; case `Zone 4`: case `Z4`: - return core.color(AppColors.Yellow); + return AppColors.Yellow; case `Zone 3`: case `Z3`: - return core.color(AppColors.Green); + return AppColors.Green; case `Zone 2`: case `Z2`: - return core.color(AppColors.Blue); + return AppColors.Blue; case `Zone 1`: case `Z1`: default: - return core.color(AppColors.LightBlue); + return AppColors.LightBlue; + } + } + + getColorForZone(zone: string): am4core.Color | null { + // Get the cached core module from the service (it will be loaded when charts are initialized) + const core = this.amChartsService.getCachedCore(); + if (!core) { + this.logger.warn('amCharts core not loaded yet'); + return null; } + + return core.color(this.getColorForZoneHex(zone)); } } diff --git a/src/app/services/echarts-loader.service.spec.ts b/src/app/services/echarts-loader.service.spec.ts new file mode 100644 index 000000000..f34729781 --- /dev/null +++ b/src/app/services/echarts-loader.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from '@angular/core/testing'; +import { NgZone, PLATFORM_ID } from '@angular/core'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { EChartsLoaderService } from './echarts-loader.service'; + +const echartsCoreMock = vi.hoisted(() => ({ + use: vi.fn(), + init: vi.fn(), +})); + +const echartsModulesMock = vi.hoisted(() => ({ + barChart: { chart: 'bar' }, + pieChart: { chart: 'pie' }, + lineChart: { chart: 'line' }, + graphicComponent: { component: 'graphic' }, + gridComponent: { component: 'grid' }, + tooltipComponent: { component: 'tooltip' }, + legendComponent: { component: 'legend' }, + titleComponent: { component: 'title' }, + axisPointerComponent: { component: 'axisPointer' }, + canvasRenderer: { renderer: 'canvas' }, +})); + +vi.mock('echarts/core', () => ({ + use: echartsCoreMock.use, + init: echartsCoreMock.init, +})); + +vi.mock('echarts/charts', () => ({ + BarChart: echartsModulesMock.barChart, + PieChart: echartsModulesMock.pieChart, + LineChart: echartsModulesMock.lineChart, +})); + +vi.mock('echarts/components', () => ({ + GridComponent: echartsModulesMock.gridComponent, + GraphicComponent: echartsModulesMock.graphicComponent, + TooltipComponent: echartsModulesMock.tooltipComponent, + LegendComponent: echartsModulesMock.legendComponent, + TitleComponent: echartsModulesMock.titleComponent, + AxisPointerComponent: echartsModulesMock.axisPointerComponent, +})); + +vi.mock('echarts/renderers', () => ({ + CanvasRenderer: echartsModulesMock.canvasRenderer, +})); + +describe('EChartsLoaderService', () => { + let service: EChartsLoaderService; + let zone: NgZone; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EChartsLoaderService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + service = TestBed.inject(EChartsLoaderService); + zone = TestBed.inject(NgZone); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load ECharts modules once and cache the core module', async () => { + const firstLoad = await service.load(); + const secondLoad = await service.load(); + + expect(firstLoad).toBe(secondLoad); + expect(echartsCoreMock.use).toHaveBeenCalledTimes(1); + expect(echartsCoreMock.use).toHaveBeenCalledWith([ + echartsModulesMock.barChart, + echartsModulesMock.pieChart, + echartsModulesMock.lineChart, + echartsModulesMock.graphicComponent, + echartsModulesMock.gridComponent, + echartsModulesMock.tooltipComponent, + echartsModulesMock.legendComponent, + echartsModulesMock.titleComponent, + echartsModulesMock.axisPointerComponent, + echartsModulesMock.canvasRenderer, + ]); + }); + + it('should deduplicate concurrent load calls', async () => { + const [coreA, coreB, coreC] = await Promise.all([ + service.load(), + service.load(), + service.load(), + ]); + + expect(coreA).toBe(coreB); + expect(coreB).toBe(coreC); + expect(echartsCoreMock.use).toHaveBeenCalledTimes(1); + }); + + it('should recover from a failed initial load and allow retry', async () => { + echartsCoreMock.use.mockImplementationOnce(() => { + throw new Error('load failed'); + }); + + await expect(service.load()).rejects.toThrow('load failed'); + expect(echartsCoreMock.use).toHaveBeenCalledTimes(1); + + const retriedCore = await service.load(); + + expect(retriedCore).toBeDefined(); + expect(echartsCoreMock.use).toHaveBeenCalledTimes(2); + }); + + it('should initialize chart instance with theme', async () => { + const chart = { id: 'chart-1' }; + const container = document.createElement('div'); + const runOutsideAngularSpy = vi.spyOn(zone, 'runOutsideAngular'); + echartsCoreMock.init.mockReturnValue(chart); + + const initialized = await service.init(container, 'dark'); + + expect(runOutsideAngularSpy).toHaveBeenCalled(); + expect(echartsCoreMock.init).toHaveBeenCalledWith(container, 'dark'); + expect(initialized).toBe(chart); + }); + + it('should delegate setOption in runOutsideAngular', () => { + const runOutsideAngularSpy = vi.spyOn(zone, 'runOutsideAngular'); + const chart = { + setOption: vi.fn(), + } as any; + + service.setOption(chart, { series: [] }, { notMerge: true }); + + expect(runOutsideAngularSpy).toHaveBeenCalled(); + expect(chart.setOption).toHaveBeenCalledWith({ series: [] }, { notMerge: true }); + }); + + it('should delegate resize in runOutsideAngular', () => { + const runOutsideAngularSpy = vi.spyOn(zone, 'runOutsideAngular'); + const chart = { + resize: vi.fn(), + } as any; + + service.resize(chart); + + expect(runOutsideAngularSpy).toHaveBeenCalled(); + expect(chart.resize).toHaveBeenCalledTimes(1); + }); + + it('should dispose active charts and skip already-disposed charts', () => { + const runOutsideAngularSpy = vi.spyOn(zone, 'runOutsideAngular'); + + const activeChart = { + isDisposed: vi.fn().mockReturnValue(false), + dispose: vi.fn(), + } as any; + + const disposedChart = { + isDisposed: vi.fn().mockReturnValue(true), + dispose: vi.fn(), + } as any; + + service.dispose(activeChart); + service.dispose(disposedChart); + service.dispose(null); + + expect(runOutsideAngularSpy).toHaveBeenCalled(); + expect(activeChart.dispose).toHaveBeenCalledTimes(1); + expect(disposedChart.dispose).not.toHaveBeenCalled(); + }); + + it('should throw when loading in non-browser platform', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + EChartsLoaderService, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + const serverService = TestBed.inject(EChartsLoaderService); + + await expect(serverService.load()).rejects.toThrow('ECharts can only be initialized in the browser.'); + }); +}); diff --git a/src/app/services/echarts-loader.service.ts b/src/app/services/echarts-loader.service.ts new file mode 100644 index 000000000..bcde3e9b7 --- /dev/null +++ b/src/app/services/echarts-loader.service.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable, NgZone, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import type { EChartsType } from 'echarts/core'; + +type EChartsCoreModule = typeof import('echarts/core'); +type EChartsOption = Parameters[0]; +type EChartsSetOptionSettings = Parameters[1]; + +@Injectable({ + providedIn: 'root' +}) +export class EChartsLoaderService { + private loader: Promise | null = null; + private cachedCore: EChartsCoreModule | null = null; + + constructor(private zone: NgZone, @Inject(PLATFORM_ID) private platformId: object) { } + + private ensureBrowser(): void { + if (!isPlatformBrowser(this.platformId)) { + throw new Error('ECharts can only be initialized in the browser.'); + } + } + + public async load(): Promise { + this.ensureBrowser(); + + if (this.cachedCore) { + return this.cachedCore; + } + + if (!this.loader) { + this.loader = (async () => { + const [core, charts, components, renderers] = await Promise.all([ + import('echarts/core'), + import('echarts/charts'), + import('echarts/components'), + import('echarts/renderers') + ]); + + core.use([ + charts.BarChart, + charts.PieChart, + charts.LineChart, + components.GraphicComponent, + components.GridComponent, + components.TooltipComponent, + components.LegendComponent, + components.TitleComponent, + components.AxisPointerComponent, + renderers.CanvasRenderer + ]); + + this.cachedCore = core; + return core; + })().catch((error) => { + // Allow retry if the first lazy-load attempt fails. + this.loader = null; + throw error; + }); + } + + return this.loader; + } + + public async init(container: HTMLElement, theme?: string): Promise { + const echarts = await this.load(); + return this.zone.runOutsideAngular(() => echarts.init(container, theme)); + } + + public setOption(chart: EChartsType, option: EChartsOption, settings?: EChartsSetOptionSettings): void { + this.zone.runOutsideAngular(() => { + chart.setOption(option, settings); + }); + } + + public resize(chart: EChartsType): void { + this.zone.runOutsideAngular(() => { + chart.resize(); + }); + } + + public dispose(chart: EChartsType | null | undefined): void { + if (!chart || chart.isDisposed()) { + return; + } + this.zone.runOutsideAngular(() => { + chart.dispose(); + }); + } +} diff --git a/src/app/services/google-maps-loader.service.ts b/src/app/services/google-maps-loader.service.ts index 86383c903..4672a2fd7 100644 --- a/src/app/services/google-maps-loader.service.ts +++ b/src/app/services/google-maps-loader.service.ts @@ -10,7 +10,7 @@ import { LoggerService } from './logger.service'; }) export class GoogleMapsLoaderService { - private appCheck = inject(AppCheck); + private appCheck = inject(AppCheck, { optional: true }); private injector = inject(EnvironmentInjector); private logger = inject(LoggerService); @@ -22,8 +22,12 @@ export class GoogleMapsLoaderService { v: 'weekly', }); - // Initialize App Check for Maps immediately - this.initializeGoogleMapsAppCheck(); + // Initialize App Check for Maps only when App Check is configured. + if (this.appCheck) { + this.initializeGoogleMapsAppCheck(); + } else { + this.logger.log('[GoogleMaps] App Check not configured, skipping token provider setup'); + } } /** @@ -40,10 +44,13 @@ export class GoogleMapsLoaderService { * See: https://developers.google.com/maps/documentation/javascript/places-app-check#step-4-initialize-the-places-and-app-check-apis */ private async initializeGoogleMapsAppCheck() { + if (!this.appCheck) { + return; + } const { Settings } = await importLibrary('core'); (Settings.getInstance() as any).fetchAppCheckToken = () => { return runInInjectionContext(this.injector, () => { - return getToken(this.appCheck).then((tokenResult) => { + return getToken(this.appCheck!).then((tokenResult) => { return { token: tokenResult.token }; }).catch((error) => { this.logger.error('[GoogleMaps] App Check token fetch failed:', error); diff --git a/src/app/services/map/marker-factory.service.spec.ts b/src/app/services/map/marker-factory.service.spec.ts index aa33f1d51..3bb3a87a3 100644 --- a/src/app/services/map/marker-factory.service.spec.ts +++ b/src/app/services/map/marker-factory.service.spec.ts @@ -65,6 +65,22 @@ describe('MarkerFactoryService', () => { expect(marker.innerHTML).toContain('fill="#aaaaaa"'); }); + it('should create jump marker with default size', () => { + const marker = service.createJumpMarker('#123123'); + expect(marker.innerHTML).toContain(' { + const marker = service.createJumpMarker('#321321', 34); + expect(marker.innerHTML).toContain(' + `); } } - diff --git a/src/app/services/my-tracks-trip-detection.service.spec.ts b/src/app/services/my-tracks-trip-detection.service.spec.ts index 66047668b..d3d47851f 100644 --- a/src/app/services/my-tracks-trip-detection.service.spec.ts +++ b/src/app/services/my-tracks-trip-detection.service.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { MyTracksTripDetectionService, TripDetectionInput } from './my-tracks-trip-detection.service'; const input = (eventId: string, startDate: string, latitudeDegrees: number, longitudeDegrees: number): TripDetectionInput => ({ @@ -11,58 +11,155 @@ const input = (eventId: string, startDate: string, latitudeDegrees: number, long describe('MyTracksTripDetectionService', () => { const service = new MyTracksTripDetectionService(); - it('detects a single qualifying trip', () => { + it('detects non-consecutive revisits with same destination id (A-B-A)', () => { const detectedTrips = service.detectTrips([ - input('a1', '2024-04-01T08:00:00Z', 27.7101, 85.3221), - input('a2', '2024-04-03T08:00:00Z', 27.7150, 85.3300), - input('a3', '2024-04-06T08:00:00Z', 27.7200, 85.3400), + input('a1', '2024-01-01T08:00:00Z', 37.9800, 23.7200), + input('a2', '2024-01-02T09:00:00Z', 37.9810, 23.7210), + input('a3', '2024-01-03T10:00:00Z', 37.9790, 23.7190), + input('a4', '2024-01-04T11:00:00Z', 37.9820, 23.7230), + input('b1', '2024-01-06T08:00:00Z', 28.2200, 83.9900), + input('b2', '2024-01-07T09:00:00Z', 28.2300, 84.0000), + input('c1', '2024-01-08T08:00:00Z', 40.6401, 22.9444), + input('c2', '2024-01-09T09:00:00Z', 40.6420, 22.9500), + input('a5', '2024-01-15T08:00:00Z', 37.9780, 23.7180), + input('a6', '2024-01-16T09:00:00Z', 37.9770, 23.7170), + input('a7', '2024-01-17T10:00:00Z', 37.9790, 23.7190), + input('a8', '2024-01-18T11:00:00Z', 37.9800, 23.7200), + ]); + + const athensTrips = detectedTrips + .filter((trip) => Math.abs(trip.centroidLat - 37.98) < 0.2 && Math.abs(trip.centroidLng - 23.72) < 0.2) + .sort((left, right) => left.startDate.getTime() - right.startDate.getTime()); + + expect(athensTrips).toHaveLength(2); + expect(athensTrips[0].destinationId).toBe(athensTrips[1].destinationId); + expect(athensTrips[0].destinationVisitIndex).toBe(1); + expect(athensTrips[1].destinationVisitIndex).toBe(2); + expect(athensTrips[0].destinationVisitCount).toBe(2); + expect(athensTrips[1].destinationVisitCount).toBe(2); + expect(athensTrips[0].isRevisit).toBe(false); + expect(athensTrips[1].isRevisit).toBe(true); + }); + + it('rejoins same-destination visits when gap is short and no destination is in between', () => { + const detectedTrips = service.detectTrips([ + input('it-1', '2025-05-07T06:00:00Z', 41.9028, 12.4964), + input('it-2', '2025-05-08T06:00:00Z', 41.9010, 12.4990), + input('it-3', '2025-05-10T06:00:00Z', 41.9050, 12.4940), + input('it-4', '2025-05-11T06:00:00Z', 41.9000, 12.4920), + input('it-5', '2025-05-14T10:00:00Z', 41.9040, 12.4950), + input('it-6', '2025-05-16T10:00:00Z', 41.9060, 12.4980), + input('it-7', '2025-05-18T10:00:00Z', 41.9030, 12.4930), + input('it-8', '2025-05-21T10:00:00Z', 41.9020, 12.4970), ]); expect(detectedTrips).toHaveLength(1); - expect(detectedTrips[0].activityCount).toBe(3); - expect(detectedTrips[0].startDate.toISOString()).toBe('2024-04-01T08:00:00.000Z'); - expect(detectedTrips[0].endDate.toISOString()).toBe('2024-04-06T08:00:00.000Z'); + expect(detectedTrips[0].activityCount).toBe(8); + expect(detectedTrips[0].destinationVisitCount).toBe(1); + expect(detectedTrips[0].isRevisit).toBe(false); + expect(detectedTrips[0].startDate.toISOString()).toBe('2025-05-07T06:00:00.000Z'); + expect(detectedTrips[0].endDate.toISOString()).toBe('2025-05-21T10:00:00.000Z'); }); - it('splits trips when locations are far apart', () => { + it('keeps A-B-C as separate destinations', () => { const detectedTrips = service.detectTrips([ - input('g1', '2024-01-01T08:00:00Z', 37.9800, 23.7200), - input('g2', '2024-01-03T08:00:00Z', 37.9700, 23.7400), - input('g3', '2024-01-05T08:00:00Z', 37.9600, 23.7600), - input('n1', '2024-01-06T08:00:00Z', 28.2200, 83.9900), - input('n2', '2024-01-08T08:00:00Z', 28.2100, 84.0100), - input('n3', '2024-01-10T08:00:00Z', 28.2000, 84.0300), + input('a1', '2024-02-01T08:00:00Z', 37.9800, 23.7200), + input('a2', '2024-02-02T09:00:00Z', 37.9810, 23.7210), + input('b1', '2024-02-03T08:00:00Z', 28.2200, 83.9900), + input('b2', '2024-02-04T09:00:00Z', 28.2300, 84.0000), + input('c1', '2024-02-05T08:00:00Z', 40.6401, 22.9444), + input('c2', '2024-02-06T09:00:00Z', 40.6420, 22.9500), ]); - expect(detectedTrips).toHaveLength(2); - expect(detectedTrips[0].tripId).toContain('g1-g3'); - expect(detectedTrips[1].tripId).toContain('n1-n3'); + expect(detectedTrips).toHaveLength(3); + expect(new Set(detectedTrips.map((trip) => trip.destinationId)).size).toBe(3); }); - it('splits trips on long time gaps', () => { + it('does not rejoin same-destination visits when another destination is in between and same-destination gap is short', () => { const detectedTrips = service.detectTrips([ - input('t1', '2024-02-01T08:00:00Z', 40.6401, 22.9444), - input('t2', '2024-02-03T08:00:00Z', 40.6420, 22.9500), - input('t3', '2024-02-05T08:00:00Z', 40.6440, 22.9550), - input('t4', '2024-02-10T08:00:00Z', 40.6405, 22.9449), - input('t5', '2024-02-12T08:00:00Z', 40.6430, 22.9510), - input('t6', '2024-02-14T08:00:00Z', 40.6450, 22.9560), + input('a-1', '2025-04-01T08:00:00Z', 37.9800, 23.7200), + input('a-2', '2025-04-02T08:00:00Z', 37.9810, 23.7210), + input('a-3', '2025-04-03T08:00:00Z', 37.9820, 23.7220), + input('a-4', '2025-04-04T08:00:00Z', 37.9830, 23.7230), + input('b-1', '2025-04-05T08:00:00Z', 28.2200, 83.9900), + input('b-2', '2025-04-06T10:00:00Z', 28.2300, 84.0000), + input('a-5', '2025-04-06T20:00:00Z', 37.9840, 23.7240), + input('a-6', '2025-04-07T20:00:00Z', 37.9850, 23.7250), + input('a-7', '2025-04-08T20:00:00Z', 37.9860, 23.7260), + input('a-8', '2025-04-09T20:00:00Z', 37.9870, 23.7270), + ]); + + const athensTrips = detectedTrips + .filter((trip) => Math.abs(trip.centroidLat - 37.9835) < 0.5 && Math.abs(trip.centroidLng - 23.7235) < 0.5) + .sort((left, right) => left.startDate.getTime() - right.startDate.getTime()); + + expect(athensTrips).toHaveLength(2); + expect(athensTrips[0].destinationId).toBe(athensTrips[1].destinationId); + expect(athensTrips[0].destinationVisitCount).toBe(2); + expect(athensTrips[1].destinationVisitCount).toBe(2); + expect(athensTrips[1].isRevisit).toBe(true); + }); + + it('splits visits for the same destination on large time gaps', () => { + const detectedTrips = service.detectTrips([ + input('d1', '2024-03-01T08:00:00Z', 39.9200, 32.8500), + input('d2', '2024-03-02T08:00:00Z', 39.9210, 32.8510), + input('d3', '2024-03-03T08:00:00Z', 39.9220, 32.8520), + input('d4', '2024-03-04T08:00:00Z', 39.9230, 32.8530), + input('d5', '2024-03-12T08:00:00Z', 39.9240, 32.8540), + input('d6', '2024-03-13T08:00:00Z', 39.9250, 32.8550), + input('d7', '2024-03-14T08:00:00Z', 39.9260, 32.8560), + input('d8', '2024-03-15T08:00:00Z', 39.9270, 32.8570), ]); expect(detectedTrips).toHaveLength(2); - expect(detectedTrips[0].activityCount).toBe(3); - expect(detectedTrips[1].activityCount).toBe(3); + expect(detectedTrips[0].destinationId).toBe(detectedTrips[1].destinationId); + expect(detectedTrips[0].destinationVisitIndex).toBe(1); + expect(detectedTrips[1].destinationVisitIndex).toBe(2); }); - it('rejects short or sparse segments', () => { + it('rejects short home-cluster windows as local noise', () => { const detectedTrips = service.detectTrips([ - input('s1', '2024-03-01T08:00:00Z', 37.9800, 23.7200), - input('s2', '2024-03-02T08:00:00Z', 37.9810, 23.7250), - input('s3', '2024-03-03T08:00:00Z', 37.9820, 23.7300), - input('s4', '2024-03-10T08:00:00Z', 37.9830, 23.7350), - input('s5', '2024-03-15T08:00:00Z', 37.9840, 23.7400), + input('h1', '2024-04-01T08:00:00Z', 37.9800, 23.7200), + input('h2', '2024-04-01T12:00:00Z', 37.9810, 23.7210), + input('h3', '2024-04-01T16:00:00Z', 37.9820, 23.7220), + input('h4', '2024-04-02T08:00:00Z', 37.9800, 23.7200), + input('h5', '2024-04-02T12:00:00Z', 37.9810, 23.7210), + input('h6', '2024-04-02T16:00:00Z', 37.9820, 23.7220), ]); expect(detectedTrips).toEqual([]); }); + + it('keeps small remote trips when only home windows are suppressed', () => { + const detectedTrips = service.detectTrips([ + input('home-1', '2024-05-01T08:00:00Z', 37.9800, 23.7200), + input('home-2', '2024-05-01T12:00:00Z', 37.9810, 23.7210), + input('home-3', '2024-05-01T16:00:00Z', 37.9820, 23.7220), + input('home-4', '2024-05-02T08:00:00Z', 37.9800, 23.7200), + input('remote-1', '2024-05-03T08:00:00Z', 28.2200, 83.9900), + input('remote-2', '2024-05-04T09:00:00Z', 28.2300, 84.0000), + ]); + + expect(detectedTrips).toHaveLength(1); + expect(detectedTrips[0].activityCount).toBe(2); + expect(Math.abs(detectedTrips[0].centroidLat - 28.225)).toBeLessThan(0.2); + expect(Math.abs(detectedTrips[0].centroidLng - 83.995)).toBeLessThan(0.2); + }); + + it('returns deterministic trip IDs regardless of input order', () => { + const dataset = [ + input('x1', '2024-06-01T08:00:00Z', 37.9800, 23.7200), + input('x2', '2024-06-02T09:00:00Z', 37.9810, 23.7210), + input('y1', '2024-06-03T08:00:00Z', 28.2200, 83.9900), + input('y2', '2024-06-04T09:00:00Z', 28.2300, 84.0000), + input('z1', '2024-06-05T08:00:00Z', 40.6401, 22.9444), + input('z2', '2024-06-06T09:00:00Z', 40.6420, 22.9500), + ]; + + const forwardTrips = service.detectTrips(dataset).map((trip) => trip.tripId); + const reversedTrips = service.detectTrips([...dataset].reverse()).map((trip) => trip.tripId); + + expect(reversedTrips).toEqual(forwardTrips); + }); }); diff --git a/src/app/services/my-tracks-trip-detection.service.ts b/src/app/services/my-tracks-trip-detection.service.ts index 6a2f5dea8..9f9eec509 100644 --- a/src/app/services/my-tracks-trip-detection.service.ts +++ b/src/app/services/my-tracks-trip-detection.service.ts @@ -8,19 +8,25 @@ export interface TripDetectionInput { longitudeDegrees: number; } +interface TripBounds { + west: number; + east: number; + south: number; + north: number; +} + export interface DetectedTrip { tripId: string; + destinationId: string; + destinationVisitIndex: number; + destinationVisitCount: number; + isRevisit: boolean; startDate: Date; endDate: Date; activityCount: number; centroidLat: number; centroidLng: number; - bounds: { - west: number; - east: number; - south: number; - north: number; - }; + bounds: TripBounds; } interface NormalizedActivityStart { @@ -37,14 +43,57 @@ interface NormalizationResult { droppedInvalidCoordinates: number; } +interface DestinationCluster { + destinationId: string; + points: NormalizedActivityStart[]; + pointShare: number; + centroidLat: number; + centroidLng: number; + bounds: TripBounds; + isNoise: boolean; +} + +interface VisitWindow { + destinationId: string; + points: NormalizedActivityStart[]; + startTimestamp: number; + endTimestamp: number; +} + +interface QualificationResult { + qualifiedVisitWindows: VisitWindow[]; + rejectionCounters: { + rejected_by_activity_count: number; + rejected_by_duration: number; + rejected_as_home_noise: number; + }; +} + +interface RejoinResult { + visitWindows: VisitWindow[]; + rejoinedVisitCount: number; +} + @Injectable({ providedIn: 'root' }) export class MyTracksTripDetectionService { - private static readonly MAX_GAP_MS = 72 * 60 * 60 * 1000; - private static readonly MAX_DISTANCE_KM = 180; - private static readonly MIN_DURATION_MS = 4 * 24 * 60 * 60 * 1000; - private static readonly MIN_ACTIVITY_COUNT = 3; + private static readonly DESTINATION_EPS_KM = 90; + private static readonly DESTINATION_MIN_POINTS = 2; + private static readonly VISIT_SPLIT_GAP_MS = 72 * 60 * 60 * 1000; + private static readonly MIN_VISIT_ACTIVITY_COUNT = 2; + private static readonly MIN_VISIT_DURATION_MS = 20 * 60 * 60 * 1000; + private static readonly HOME_CLUSTER_MIN_SHARE = 0.35; + private static readonly HOME_MIN_ACTIVITY_COUNT = 4; + private static readonly HOME_MIN_DURATION_MS = 72 * 60 * 60 * 1000; + private static readonly SAME_DESTINATION_REJOIN_MAX_GAP_MS = 5 * 24 * 60 * 60 * 1000; + private static readonly DESTINATION_ID_ROUNDING_DECIMALS = 3; + private static readonly ENABLE_LEGACY_COMPARISON_LOG = false; + + private static readonly LEGACY_MAX_GAP_MS = 72 * 60 * 60 * 1000; + private static readonly LEGACY_MAX_DISTANCE_KM = 180; + private static readonly LEGACY_MIN_DURATION_MS = 4 * 24 * 60 * 60 * 1000; + private static readonly LEGACY_MIN_ACTIVITY_COUNT = 3; constructor( private logger: LoggerService = new LoggerService(), @@ -72,66 +121,484 @@ export class MyTracksTripDetectionService { return []; } - const segments: NormalizedActivityStart[][] = []; - let currentSegment: NormalizedActivityStart[] = []; + const destinationClusters = this.clusterDestinations(normalized); + const visitWindows = this.buildVisitWindows(destinationClusters, normalized); + const homeDestinationId = this.identifyHomeDestination(destinationClusters); + const qualificationResult = this.qualifyVisitWindows(visitWindows, homeDestinationId); + const rejoinResult = this.mergeNearbyVisitWindowsWithoutInterleavingDestinations(qualificationResult.qualifiedVisitWindows); + const detectedTrips = this.mapVisitWindowsToTrips(rejoinResult.visitWindows); + + if (MyTracksTripDetectionService.ENABLE_LEGACY_COMPARISON_LOG) { + const legacyCount = this.detectTripsLegacyCount(normalized); + this.logger.log('[MyTracksTripDetectionService] Legacy vs V2 comparison.', { + legacyCount, + v2Count: detectedTrips.length, + }); + } + + this.logger.log('[MyTracksTripDetectionService] Trip detection completed.', { + inputCount: inputs.length, + normalizedCount: normalized.length, + clusterCount: destinationClusters.filter((cluster) => !cluster.isNoise).length, + visitWindowCount: visitWindows.length, + rejoinedVisitCount: rejoinResult.rejoinedVisitCount, + qualifiedVisitCount: detectedTrips.length, + homeClusterDetected: !!homeDestinationId, + rejectionCounters: qualificationResult.rejectionCounters, + thresholds: { + destinationEpsKm: MyTracksTripDetectionService.DESTINATION_EPS_KM, + destinationMinPoints: MyTracksTripDetectionService.DESTINATION_MIN_POINTS, + visitSplitGapHours: MyTracksTripDetectionService.VISIT_SPLIT_GAP_MS / (60 * 60 * 1000), + nonHomeMinActivityCount: MyTracksTripDetectionService.MIN_VISIT_ACTIVITY_COUNT, + nonHomeMinDurationHours: MyTracksTripDetectionService.MIN_VISIT_DURATION_MS / (60 * 60 * 1000), + homeClusterMinShare: MyTracksTripDetectionService.HOME_CLUSTER_MIN_SHARE, + homeMinActivityCount: MyTracksTripDetectionService.HOME_MIN_ACTIVITY_COUNT, + homeMinDurationHours: MyTracksTripDetectionService.HOME_MIN_DURATION_MS / (60 * 60 * 1000), + sameDestinationRejoinGapHours: MyTracksTripDetectionService.SAME_DESTINATION_REJOIN_MAX_GAP_MS / (60 * 60 * 1000), + } + }); + + return detectedTrips; + } - normalized.forEach((entry, index) => { - if (index === 0) { - currentSegment.push(entry); + private clusterDestinations(entries: NormalizedActivityStart[]): DestinationCluster[] { + const labels = this.runDbscan(entries); + const clusterMap = new Map(); + const noisePoints: Array<{ point: NormalizedActivityStart; index: number }> = []; + + labels.forEach((label, index) => { + if (label >= 0) { + if (!clusterMap.has(label)) { + clusterMap.set(label, []); + } + clusterMap.get(label)!.push(entries[index]); return; } - const previous = normalized[index - 1]; - const gapMs = entry.timestamp - previous.timestamp; + noisePoints.push({ + point: entries[index], + index, + }); + }); + + const totalPointCount = entries.length; + const clusters: DestinationCluster[] = []; + const sortedClusters = Array.from(clusterMap.entries()).sort((a, b) => a[0] - b[0]); + + sortedClusters.forEach(([clusterIndex, points]) => { + const summary = this.summarizePoints(points); + clusters.push({ + destinationId: this.createDestinationId(summary.centroidLat, summary.centroidLng, clusterIndex), + points, + pointShare: points.length / totalPointCount, + centroidLat: summary.centroidLat, + centroidLng: summary.centroidLng, + bounds: summary.bounds, + isNoise: false, + }); + }); + + noisePoints.forEach((noisePoint) => { + clusters.push({ + destinationId: `noise_${noisePoint.point.eventId}_${noisePoint.point.timestamp}_${noisePoint.index}`, + points: [noisePoint.point], + pointShare: 1 / totalPointCount, + centroidLat: noisePoint.point.latitudeDegrees, + centroidLng: noisePoint.point.longitudeDegrees, + bounds: { + west: noisePoint.point.longitudeDegrees, + east: noisePoint.point.longitudeDegrees, + south: noisePoint.point.latitudeDegrees, + north: noisePoint.point.latitudeDegrees, + }, + isNoise: true, + }); + }); + + return clusters; + } + + private runDbscan(entries: NormalizedActivityStart[]): number[] { + const unassignedLabel = -99; + const noiseLabel = -1; + const labels = new Array(entries.length).fill(unassignedLabel); + const visited = new Array(entries.length).fill(false); + let nextClusterLabel = 0; + + for (let index = 0; index < entries.length; index++) { + if (visited[index]) continue; + + visited[index] = true; + const neighbors = this.findNeighbors(entries, index, MyTracksTripDetectionService.DESTINATION_EPS_KM); + + if (neighbors.length < MyTracksTripDetectionService.DESTINATION_MIN_POINTS) { + labels[index] = noiseLabel; + continue; + } + + this.expandCluster(entries, index, neighbors, nextClusterLabel, labels, visited, unassignedLabel, noiseLabel); + nextClusterLabel += 1; + } + + return labels; + } + + private expandCluster( + entries: NormalizedActivityStart[], + seedIndex: number, + seedNeighbors: number[], + clusterLabel: number, + labels: number[], + visited: boolean[], + unassignedLabel: number, + noiseLabel: number, + ): void { + labels[seedIndex] = clusterLabel; + const queue = [...seedNeighbors]; + const queued = new Set(seedNeighbors); + + while (queue.length > 0) { + const currentIndex = queue.shift()!; + queued.delete(currentIndex); + + if (!visited[currentIndex]) { + visited[currentIndex] = true; + const currentNeighbors = this.findNeighbors(entries, currentIndex, MyTracksTripDetectionService.DESTINATION_EPS_KM); + if (currentNeighbors.length >= MyTracksTripDetectionService.DESTINATION_MIN_POINTS) { + currentNeighbors.forEach((neighborIndex) => { + if (queued.has(neighborIndex)) return; + queue.push(neighborIndex); + queued.add(neighborIndex); + }); + } + } + + if (labels[currentIndex] === unassignedLabel || labels[currentIndex] === noiseLabel) { + labels[currentIndex] = clusterLabel; + } + } + } + + private findNeighbors(entries: NormalizedActivityStart[], index: number, epsKm: number): number[] { + const source = entries[index]; + const neighbors: number[] = []; + + entries.forEach((candidate, candidateIndex) => { const distanceKm = this.haversineDistanceKm( - previous.latitudeDegrees, - previous.longitudeDegrees, - entry.latitudeDegrees, - entry.longitudeDegrees + source.latitudeDegrees, + source.longitudeDegrees, + candidate.latitudeDegrees, + candidate.longitudeDegrees ); - const shouldStartNewSegment = gapMs > MyTracksTripDetectionService.MAX_GAP_MS - || distanceKm > MyTracksTripDetectionService.MAX_DISTANCE_KM; - - if (shouldStartNewSegment) { - this.logger.log('[MyTracksTripDetectionService] Starting a new segment.', { - previousEventId: previous.eventId, - nextEventId: entry.eventId, - gapHours: Number((gapMs / (60 * 60 * 1000)).toFixed(2)), - distanceKm: Number(distanceKm.toFixed(2)), - splitByTimeGap: gapMs > MyTracksTripDetectionService.MAX_GAP_MS, - splitByDistance: distanceKm > MyTracksTripDetectionService.MAX_DISTANCE_KM, + if (distanceKm <= epsKm) { + neighbors.push(candidateIndex); + } + }); + + return neighbors; + } + + private buildVisitWindows( + destinationClusters: DestinationCluster[], + timelinePoints: NormalizedActivityStart[], + ): VisitWindow[] { + if (timelinePoints.length === 0) { + return []; + } + + const destinationByPoint = new Map(); + destinationClusters.forEach((cluster) => { + cluster.points.forEach((point) => { + destinationByPoint.set(point, cluster.destinationId); + }); + }); + + const visitWindows: VisitWindow[] = []; + let currentDestinationId: string | null = null; + let currentWindowPoints: NormalizedActivityStart[] = []; + + timelinePoints.forEach((point) => { + const destinationId = destinationByPoint.get(point); + if (!destinationId) { + this.logger.warn('[MyTracksTripDetectionService] Missing destination assignment for timeline point.', { + eventId: point.eventId, + timestamp: point.timestamp, }); - if (currentSegment.length > 0) { - segments.push(currentSegment); + return; + } + + if (!currentDestinationId) { + currentDestinationId = destinationId; + currentWindowPoints = [point]; + return; + } + + const previousPoint = currentWindowPoints[currentWindowPoints.length - 1]; + const gapMs = point.timestamp - previousPoint.timestamp; + const hasDestinationChanged = destinationId !== currentDestinationId; + const hasLargeGap = gapMs > MyTracksTripDetectionService.VISIT_SPLIT_GAP_MS; + + if (hasDestinationChanged || hasLargeGap) { + visitWindows.push(this.createVisitWindow(currentDestinationId, currentWindowPoints)); + currentDestinationId = destinationId; + currentWindowPoints = [point]; + return; + } + + currentWindowPoints.push(point); + }); + + if (currentDestinationId && currentWindowPoints.length > 0) { + visitWindows.push(this.createVisitWindow(currentDestinationId, currentWindowPoints)); + } + + return visitWindows + .sort((a, b) => a.startTimestamp - b.startTimestamp || a.destinationId.localeCompare(b.destinationId)); + } + + private createVisitWindow(destinationId: string, points: NormalizedActivityStart[]): VisitWindow { + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + return { + destinationId, + points, + startTimestamp: firstPoint.timestamp, + endTimestamp: lastPoint.timestamp, + }; + } + + private identifyHomeDestination(destinationClusters: DestinationCluster[]): string | null { + const nonNoiseClusters = destinationClusters.filter((cluster) => !cluster.isNoise); + if (nonNoiseClusters.length === 0) { + return null; + } + + const sortedByShare = [...nonNoiseClusters].sort((a, b) => { + if (b.pointShare !== a.pointShare) { + return b.pointShare - a.pointShare; + } + return a.destinationId.localeCompare(b.destinationId); + }); + + const strongestCluster = sortedByShare[0]; + if (strongestCluster.pointShare < MyTracksTripDetectionService.HOME_CLUSTER_MIN_SHARE) { + return null; + } + + return strongestCluster.destinationId; + } + + private qualifyVisitWindows(visitWindows: VisitWindow[], homeDestinationId: string | null): QualificationResult { + const rejectionCounters = { + rejected_by_activity_count: 0, + rejected_by_duration: 0, + rejected_as_home_noise: 0, + }; + const qualifiedVisitWindows: VisitWindow[] = []; + + visitWindows.forEach((visitWindow) => { + const activityCount = visitWindow.points.length; + const durationMs = visitWindow.endTimestamp - visitWindow.startTimestamp; + const isHomeWindow = !!homeDestinationId && visitWindow.destinationId === homeDestinationId; + + if (isHomeWindow) { + const rejectedAsHomeNoise = activityCount < MyTracksTripDetectionService.HOME_MIN_ACTIVITY_COUNT + || durationMs < MyTracksTripDetectionService.HOME_MIN_DURATION_MS; + + if (rejectedAsHomeNoise) { + rejectionCounters.rejected_as_home_noise += 1; + return; } - currentSegment = [entry]; + + qualifiedVisitWindows.push(visitWindow); return; } - currentSegment.push(entry); + if (activityCount < MyTracksTripDetectionService.MIN_VISIT_ACTIVITY_COUNT) { + rejectionCounters.rejected_by_activity_count += 1; + return; + } + + if (durationMs < MyTracksTripDetectionService.MIN_VISIT_DURATION_MS) { + rejectionCounters.rejected_by_duration += 1; + return; + } + + qualifiedVisitWindows.push(visitWindow); }); - if (currentSegment.length > 0) { - segments.push(currentSegment); + return { + qualifiedVisitWindows, + rejectionCounters, + }; + } + + private mergeNearbyVisitWindowsWithoutInterleavingDestinations(visitWindows: VisitWindow[]): RejoinResult { + if (visitWindows.length <= 1) { + return { + visitWindows: [...visitWindows], + rejoinedVisitCount: 0, + }; } - const qualifiedSegments = segments - .filter((segment, index) => this.isQualifyingTrip(segment, index)) - .map((segment) => this.toDetectedTrip(segment)); + const sortedVisitWindows = [...visitWindows] + .sort((a, b) => a.startTimestamp - b.startTimestamp || a.destinationId.localeCompare(b.destinationId)); + + const mergedVisitWindows: VisitWindow[] = []; + let currentWindow = sortedVisitWindows[0]; + let rejoinedVisitCount = 0; + + for (let index = 1; index < sortedVisitWindows.length; index++) { + const nextWindow = sortedVisitWindows[index]; + const gapMs = nextWindow.startTimestamp - currentWindow.endTimestamp; + const isSameDestination = currentWindow.destinationId === nextWindow.destinationId; + const hasShortGap = gapMs >= 0 && gapMs <= MyTracksTripDetectionService.SAME_DESTINATION_REJOIN_MAX_GAP_MS; + + if (isSameDestination && hasShortGap) { + currentWindow = { + destinationId: currentWindow.destinationId, + points: [...currentWindow.points, ...nextWindow.points] + .sort((a, b) => a.timestamp - b.timestamp || a.eventId.localeCompare(b.eventId)), + startTimestamp: currentWindow.startTimestamp, + endTimestamp: Math.max(currentWindow.endTimestamp, nextWindow.endTimestamp), + }; + rejoinedVisitCount += 1; + continue; + } - this.logger.log('[MyTracksTripDetectionService] Trip detection completed.', { - segmentCount: segments.length, - detectedTripCount: qualifiedSegments.length, - thresholds: { - maxGapHours: MyTracksTripDetectionService.MAX_GAP_MS / (60 * 60 * 1000), - maxDistanceKm: MyTracksTripDetectionService.MAX_DISTANCE_KM, - minDurationDays: MyTracksTripDetectionService.MIN_DURATION_MS / (24 * 60 * 60 * 1000), - minActivityCount: MyTracksTripDetectionService.MIN_ACTIVITY_COUNT, + mergedVisitWindows.push(currentWindow); + currentWindow = nextWindow; + } + + mergedVisitWindows.push(currentWindow); + + return { + visitWindows: mergedVisitWindows, + rejoinedVisitCount, + }; + } + + private mapVisitWindowsToTrips(qualifiedVisitWindows: VisitWindow[]): DetectedTrip[] { + const groupedByDestination = new Map(); + qualifiedVisitWindows.forEach((visitWindow) => { + if (!groupedByDestination.has(visitWindow.destinationId)) { + groupedByDestination.set(visitWindow.destinationId, []); } + groupedByDestination.get(visitWindow.destinationId)!.push(visitWindow); }); - return qualifiedSegments; + const detectedTrips: DetectedTrip[] = []; + groupedByDestination.forEach((windowsForDestination) => { + const sortedWindows = [...windowsForDestination] + .sort((a, b) => a.startTimestamp - b.startTimestamp || a.endTimestamp - b.endTimestamp); + const destinationVisitCount = sortedWindows.length; + + sortedWindows.forEach((visitWindow, index) => { + detectedTrips.push(this.toDetectedTrip(visitWindow, index + 1, destinationVisitCount)); + }); + }); + + return detectedTrips + .sort((a, b) => a.startDate.getTime() - b.startDate.getTime() || a.destinationId.localeCompare(b.destinationId)); + } + + private toDetectedTrip(visitWindow: VisitWindow, destinationVisitIndex: number, destinationVisitCount: number): DetectedTrip { + const summary = this.summarizePoints(visitWindow.points); + const startTimestamp = visitWindow.startTimestamp; + const endTimestamp = visitWindow.endTimestamp; + const activityCount = visitWindow.points.length; + + return { + tripId: `trip_${visitWindow.destinationId}_${startTimestamp}_${endTimestamp}_${activityCount}`, + destinationId: visitWindow.destinationId, + destinationVisitIndex, + destinationVisitCount, + isRevisit: destinationVisitIndex > 1, + startDate: new Date(startTimestamp), + endDate: new Date(endTimestamp), + activityCount, + centroidLat: summary.centroidLat, + centroidLng: summary.centroidLng, + bounds: summary.bounds, + }; + } + + private summarizePoints(points: NormalizedActivityStart[]): { + centroidLat: number; + centroidLng: number; + bounds: TripBounds; + } { + const centroid = points.reduce((accumulator, point) => { + accumulator.lat += point.latitudeDegrees; + accumulator.lng += point.longitudeDegrees; + return accumulator; + }, { lat: 0, lng: 0 }); + + const bounds = points.reduce((accumulator, point) => { + accumulator.west = Math.min(accumulator.west, point.longitudeDegrees); + accumulator.east = Math.max(accumulator.east, point.longitudeDegrees); + accumulator.south = Math.min(accumulator.south, point.latitudeDegrees); + accumulator.north = Math.max(accumulator.north, point.latitudeDegrees); + return accumulator; + }, { + west: points[0].longitudeDegrees, + east: points[0].longitudeDegrees, + south: points[0].latitudeDegrees, + north: points[0].latitudeDegrees, + }); + + return { + centroidLat: centroid.lat / points.length, + centroidLng: centroid.lng / points.length, + bounds, + }; + } + + private createDestinationId(centroidLat: number, centroidLng: number, clusterIndex: number): string { + const roundedLat = centroidLat.toFixed(MyTracksTripDetectionService.DESTINATION_ID_ROUNDING_DECIMALS); + const roundedLng = centroidLng.toFixed(MyTracksTripDetectionService.DESTINATION_ID_ROUNDING_DECIMALS); + return `destination_${roundedLat}_${roundedLng}_${clusterIndex}`; + } + + private detectTripsLegacyCount(normalized: NormalizedActivityStart[]): number { + if (normalized.length === 0) return 0; + + const segments: NormalizedActivityStart[][] = []; + let currentSegment: NormalizedActivityStart[] = [normalized[0]]; + + for (let index = 1; index < normalized.length; index++) { + const current = normalized[index]; + const previous = normalized[index - 1]; + const gapMs = current.timestamp - previous.timestamp; + const distanceKm = this.haversineDistanceKm( + previous.latitudeDegrees, + previous.longitudeDegrees, + current.latitudeDegrees, + current.longitudeDegrees + ); + + const shouldSplit = gapMs > MyTracksTripDetectionService.LEGACY_MAX_GAP_MS + || distanceKm > MyTracksTripDetectionService.LEGACY_MAX_DISTANCE_KM; + + if (shouldSplit) { + segments.push(currentSegment); + currentSegment = [current]; + continue; + } + + currentSegment.push(current); + } + + if (currentSegment.length > 0) { + segments.push(currentSegment); + } + + return segments.filter((segment) => { + if (segment.length < MyTracksTripDetectionService.LEGACY_MIN_ACTIVITY_COUNT) return false; + const durationMs = segment[segment.length - 1].timestamp - segment[0].timestamp; + return durationMs >= MyTracksTripDetectionService.LEGACY_MIN_DURATION_MS; + }).length; } private normalize(inputs: TripDetectionInput[]): NormalizationResult { @@ -167,7 +634,7 @@ export class MyTracksTripDetectionService { } satisfies NormalizedActivityStart); }); - entries.sort((a, b) => a.timestamp - b.timestamp); + entries.sort((a, b) => a.timestamp - b.timestamp || a.eventId.localeCompare(b.eventId)); return { entries, @@ -177,68 +644,6 @@ export class MyTracksTripDetectionService { }; } - private isQualifyingTrip(segment: NormalizedActivityStart[], segmentIndex: number): boolean { - if (segment.length < MyTracksTripDetectionService.MIN_ACTIVITY_COUNT) { - this.logger.log('[MyTracksTripDetectionService] Segment rejected by minimum activity threshold.', { - segmentIndex, - activityCount: segment.length, - requiredActivityCount: MyTracksTripDetectionService.MIN_ACTIVITY_COUNT, - }); - return false; - } - - const first = segment[0]; - const last = segment[segment.length - 1]; - const durationMs = last.timestamp - first.timestamp; - - if (durationMs < MyTracksTripDetectionService.MIN_DURATION_MS) { - this.logger.log('[MyTracksTripDetectionService] Segment rejected by minimum duration threshold.', { - segmentIndex, - durationDays: Number((durationMs / (24 * 60 * 60 * 1000)).toFixed(2)), - requiredDurationDays: MyTracksTripDetectionService.MIN_DURATION_MS / (24 * 60 * 60 * 1000), - }); - return false; - } - - return true; - } - - private toDetectedTrip(segment: NormalizedActivityStart[]): DetectedTrip { - const first = segment[0]; - const last = segment[segment.length - 1]; - const centroid = segment.reduce((accumulator, point) => { - accumulator.lat += point.latitudeDegrees; - accumulator.lng += point.longitudeDegrees; - return accumulator; - }, { lat: 0, lng: 0 }); - const bounds = segment.reduce((accumulator, point) => { - accumulator.west = Math.min(accumulator.west, point.longitudeDegrees); - accumulator.east = Math.max(accumulator.east, point.longitudeDegrees); - accumulator.south = Math.min(accumulator.south, point.latitudeDegrees); - accumulator.north = Math.max(accumulator.north, point.latitudeDegrees); - return accumulator; - }, { - west: segment[0].longitudeDegrees, - east: segment[0].longitudeDegrees, - south: segment[0].latitudeDegrees, - north: segment[0].latitudeDegrees, - }); - - return { - tripId: this.getTripId(first, last, segment.length), - startDate: new Date(first.timestamp), - endDate: new Date(last.timestamp), - activityCount: segment.length, - centroidLat: centroid.lat / segment.length, - centroidLng: centroid.lng / segment.length, - bounds, - }; - } - - private getTripId(first: NormalizedActivityStart, last: NormalizedActivityStart, activityCount: number): string { - return `${first.eventId}-${last.eventId}-${first.timestamp}-${last.timestamp}-${activityCount}`; - } - private toTimestamp(value: Date | number | string | undefined): number | null { if (value instanceof Date) { const dateValue = value.getTime(); diff --git a/src/app/services/storage/app.chart.settings.local.storage.service.spec.ts b/src/app/services/storage/app.chart.settings.local.storage.service.spec.ts new file mode 100644 index 000000000..23e7ca6a7 --- /dev/null +++ b/src/app/services/storage/app.chart.settings.local.storage.service.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { EventInterface } from '@sports-alliance/sports-lib'; +import { afterEach, describe, expect, it } from 'vitest'; +import { APP_STORAGE } from './app.storage.token'; +import { MemoryStorage } from './memory.storage'; +import { AppChartSettingsLocalStorageService } from './app.chart.settings.local.storage.service'; + +describe('AppChartSettingsLocalStorageService', () => { + let service: AppChartSettingsLocalStorageService; + let storage: Storage; + + const mockEvent = (id: string): EventInterface => ({ + getID: () => id, + } as any); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AppChartSettingsLocalStorageService, + { provide: APP_STORAGE, useClass: MemoryStorage }, + ], + }); + + service = TestBed.inject(AppChartSettingsLocalStorageService); + storage = TestBed.inject(APP_STORAGE); + }); + + afterEach(() => { + storage.clear(); + }); + + it('should persist and restore fully qualified series ids without normalization', () => { + const event = mockEvent('event-1'); + const fullIds = [ + 'Average speed in kilometers per hour', + 'Average speed in miles per hour', + 'Average Power', + ]; + + service.setSeriesIDsToShow(event, fullIds); + expect(service.getSeriesIDsToShow(event)).toEqual(fullIds); + }); + + it('should add and remove exact series ids (no label transformation)', () => { + const event = mockEvent('event-2'); + const fullId = 'Average speed in kilometers per hour'; + + service.showSeriesID(event, fullId); + expect(service.getSeriesIDsToShow(event)).toEqual([fullId]); + + service.hideSeriesID(event, fullId); + expect(service.getSeriesIDsToShow(event)).toEqual([]); + }); +}); diff --git a/src/app/services/storage/app.event-summary-tabs.local.storage.service.spec.ts b/src/app/services/storage/app.event-summary-tabs.local.storage.service.spec.ts new file mode 100644 index 000000000..ab8be7a6d --- /dev/null +++ b/src/app/services/storage/app.event-summary-tabs.local.storage.service.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { APP_STORAGE } from './app.storage.token'; +import { MemoryStorage } from './memory.storage'; +import { AppEventSummaryTabsLocalStorageService } from './app.event-summary-tabs.local.storage.service'; + +describe('AppEventSummaryTabsLocalStorageService', () => { + let service: AppEventSummaryTabsLocalStorageService; + let storage: Storage; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AppEventSummaryTabsLocalStorageService, + { provide: APP_STORAGE, useClass: MemoryStorage }, + ], + }); + + service = TestBed.inject(AppEventSummaryTabsLocalStorageService); + storage = TestBed.inject(APP_STORAGE); + }); + + afterEach(() => { + storage.clear(); + }); + + it('should return empty string when no tab id is stored', () => { + expect(service.getLastSelectedStatsTabId()).toBe(''); + }); + + it('should store and read the last selected tab id', () => { + service.setLastSelectedStatsTabId('performance'); + expect(service.getLastSelectedStatsTabId()).toBe('performance'); + }); + + it('should clear the last selected tab id', () => { + service.setLastSelectedStatsTabId('environment'); + service.clearLastSelectedStatsTabId(); + expect(service.getLastSelectedStatsTabId()).toBe(''); + }); +}); diff --git a/src/app/services/storage/app.event-summary-tabs.local.storage.service.ts b/src/app/services/storage/app.event-summary-tabs.local.storage.service.ts new file mode 100644 index 000000000..bfc65132b --- /dev/null +++ b/src/app/services/storage/app.event-summary-tabs.local.storage.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { EventSummaryMetricGroupId } from '../../constants/event-summary-metric-groups'; +import { LocalStorageService } from './app.local.storage.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AppEventSummaryTabsLocalStorageService extends LocalStorageService { + protected nameSpace = 'event.summary.tabs.'; + + private readonly lastSelectedStatsTabIdKey = 'lastSelectedStatsTabId'; + + public getLastSelectedStatsTabId(): string { + return this.getItem(this.lastSelectedStatsTabIdKey); + } + + public setLastSelectedStatsTabId(tabId: EventSummaryMetricGroupId): void { + this.setItem(this.lastSelectedStatsTabIdKey, tabId); + } + + public clearLastSelectedStatsTabId(): void { + this.removeItem(this.lastSelectedStatsTabIdKey); + } +} diff --git a/src/app/services/trip-location-label.service.spec.ts b/src/app/services/trip-location-label.service.spec.ts index a7e951893..77c25450d 100644 --- a/src/app/services/trip-location-label.service.spec.ts +++ b/src/app/services/trip-location-label.service.spec.ts @@ -1,22 +1,37 @@ +import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TripLocationLabelService } from './trip-location-label.service'; +import { LoggerService } from './logger.service'; describe('TripLocationLabelService', () => { let service: TripLocationLabelService; + const loggerMock = { + log: vi.fn(), + }; beforeEach(() => { - service = new TripLocationLabelService(); + TestBed.configureTestingModule({ + providers: [ + TripLocationLabelService, + { provide: LoggerService, useValue: loggerMock }, + ], + }); + service = TestBed.inject(TripLocationLabelService); }); afterEach(() => { vi.restoreAllMocks(); }); - it('resolves country labels from mapbox geocoding response', async () => { + it('returns City, Country labels when place and country are available', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ features: [ + { + text: 'Kathmandu', + place_type: ['place'] + }, { text: 'Nepal', place_type: ['country'] @@ -25,17 +40,103 @@ describe('TripLocationLabelService', () => { }) } as unknown as Response); - const label = await service.resolveCountryName(27.7172, 85.3240); + const resolved = await service.resolveTripLocation(27.7172, 85.3240); - expect(label).toBe('Nepal'); + expect(resolved).toEqual({ + city: 'Kathmandu', + country: 'Nepal', + label: 'Kathmandu, Nepal', + }); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + it('returns country-only labels when no city-like feature exists', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + features: [ + { + text: 'Ankara Region', + place_type: ['region'] + }, + { + text: 'Turkey', + place_type: ['country'] + } + ] + }) + } as unknown as Response); + + const resolved = await service.resolveTripLocation(39.92032, 32.85411); + + expect(resolved).toEqual({ + city: null, + country: 'Turkey', + label: 'Turkey', + }); + }); + + it('falls back to district labels when place/locality are unavailable', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + features: [ + { + text: 'Kathmandu District', + place_type: ['district'] + }, + { + text: 'Nepal', + place_type: ['country'] + } + ] + }) + } as unknown as Response); + + const resolved = await service.resolveTripLocation(27.70, 85.32); + + expect(resolved).toEqual({ + city: 'Kathmandu District', + country: 'Nepal', + label: 'Kathmandu District, Nepal', + }); + }); + + it('extracts country from feature context when country is not returned as a top-level feature', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + features: [ + { + text: 'Kathmandu', + place_type: ['place'], + context: [ + { id: 'district.1234', text: 'Kathmandu District' }, + { id: 'country.5678', text: 'Nepal' } + ] + } + ] + }) + } as unknown as Response); + + const resolved = await service.resolveTripLocation(27.7172, 85.3240); + + expect(resolved).toEqual({ + city: 'Kathmandu', + country: 'Nepal', + label: 'Kathmandu, Nepal', + }); + }); + it('uses cache for repeated centroid lookups', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ features: [ + { + text: 'Ankara', + place_type: ['place'] + }, { text: 'Turkey', place_type: ['country'] @@ -44,11 +145,11 @@ describe('TripLocationLabelService', () => { }) } as unknown as Response); - const first = await service.resolveCountryName(39.92032, 32.85411); - const second = await service.resolveCountryName(39.92039, 32.85419); + const first = await service.resolveTripLocation(39.92032, 32.85411); + const second = await service.resolveTripLocation(39.92039, 32.85419); - expect(first).toBe('Turkey'); - expect(second).toBe('Turkey'); + expect(first?.label).toBe('Ankara, Turkey'); + expect(second?.label).toBe('Ankara, Turkey'); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -58,8 +159,30 @@ describe('TripLocationLabelService', () => { json: async () => ({}) } as unknown as Response); - const label = await service.resolveCountryName(0, 0); + const resolved = await service.resolveTripLocation(0, 0); + + expect(resolved).toBeNull(); + }); + + it('keeps resolveCountryName for backward compatibility', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + features: [ + { + text: 'Tokyo', + place_type: ['place'] + }, + { + text: 'Japan', + place_type: ['country'] + } + ] + }) + } as unknown as Response); + + const country = await service.resolveCountryName(35.6764, 139.6500); - expect(label).toBeNull(); + expect(country).toBe('Japan'); }); }); diff --git a/src/app/services/trip-location-label.service.ts b/src/app/services/trip-location-label.service.ts index 8ba05a310..22797e985 100644 --- a/src/app/services/trip-location-label.service.ts +++ b/src/app/services/trip-location-label.service.ts @@ -1,10 +1,23 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { environment } from '../../environments/environment'; +import { LoggerService } from './logger.service'; + +export interface ResolvedTripLocationLabel { + city: string | null; + country: string | null; + label: string | null; +} interface MapboxFeature { text?: string; place_name?: string; place_type?: string[]; + context?: MapboxContextFeature[]; +} + +interface MapboxContextFeature { + id?: string; + text?: string; } interface MapboxReverseGeocodingResponse { @@ -16,9 +29,15 @@ interface MapboxReverseGeocodingResponse { }) export class TripLocationLabelService { private static readonly ROUNDING_PRECISION = 2; - private readonly cache = new Map>(); + private readonly cache = new Map>(); + private readonly logger = inject(LoggerService); public resolveCountryName(latitudeDegrees: number, longitudeDegrees: number): Promise { + return this.resolveTripLocation(latitudeDegrees, longitudeDegrees) + .then((resolved) => resolved?.country ?? null); + } + + public resolveTripLocation(latitudeDegrees: number, longitudeDegrees: number): Promise { if (!Number.isFinite(latitudeDegrees) || !Number.isFinite(longitudeDegrees)) { return Promise.resolve(null); } @@ -33,8 +52,15 @@ export class TripLocationLabelService { return cachedLookup; } - const lookup = this.fetchCountryName(latitudeDegrees, longitudeDegrees) - .catch(() => null); + const lookup = this.fetchTripLocation(latitudeDegrees, longitudeDegrees) + .catch((error) => { + this.logger.log('[TripLocationLabelService] Geocoding failed while resolving trip location.', { + latitudeDegrees, + longitudeDegrees, + error, + }); + return null; + }); this.cache.set(cacheKey, lookup); return lookup; @@ -46,27 +72,112 @@ export class TripLocationLabelService { return `${roundedLat},${roundedLng}`; } - private async fetchCountryName(latitudeDegrees: number, longitudeDegrees: number): Promise { + private async fetchTripLocation(latitudeDegrees: number, longitudeDegrees: number): Promise { const token = environment.mapboxAccessToken; - if (!token) return null; + if (!token) { + this.logger.log('[TripLocationLabelService] Mapbox token missing while resolving trip location.'); + return null; + } - const endpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${longitudeDegrees},${latitudeDegrees}.json?types=country&access_token=${token}`; + // Mapbox reverse geocoding rejects `limit` when multiple `types` are provided. + const endpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${longitudeDegrees},${latitudeDegrees}.json?types=place,locality,district,region,country&access_token=${token}`; const response = await fetch(endpoint); if (!response.ok) { + this.logger.log('[TripLocationLabelService] Mapbox geocoding returned non-OK status.', { + latitudeDegrees, + longitudeDegrees, + status: response.status, + }); return null; } const payload = await response.json() as MapboxReverseGeocodingResponse; - const countryFeature = payload.features?.find((feature) => feature.place_type?.includes('country')); - const fallbackFeature = payload.features?.[0]; - const label = countryFeature?.text || countryFeature?.place_name || fallbackFeature?.text || fallbackFeature?.place_name; + const features = payload.features || []; - if (!label) { - return null; + const primaryFeature = features[0] || null; + const cityFeature = this.findFirstFeatureByType(features, ['place', 'locality', 'district']); + const countryFeature = this.findFirstFeatureByType(features, ['country']); + + const city = this.normalizeFeatureLabel(cityFeature) + || this.normalizeContextLabel(this.findFirstContextByType(primaryFeature, ['place', 'locality', 'district'])); + const country = this.normalizeFeatureLabel(countryFeature) + || this.normalizeContextLabel(this.findFirstContextByType(primaryFeature, ['country'])); + const label = this.composeLabel(city, country); + + const fallbackPath = this.resolveFallbackPath(city, country); + this.logger.log('[TripLocationLabelService] Parsed trip location label.', { + latitudeDegrees, + longitudeDegrees, + city, + country, + label, + fallbackPath, + }); + + if (!label) return null; + + return { + city, + country, + label, + }; + } + + private findFirstFeatureByType(features: MapboxFeature[], placeTypes: string[]): MapboxFeature | null { + for (const placeType of placeTypes) { + const feature = features.find((candidate) => candidate.place_type?.includes(placeType)); + if (feature) return feature; + } + + return null; + } + + private findFirstContextByType(feature: MapboxFeature | null, placeTypes: string[]): MapboxContextFeature | null { + const contexts = feature?.context || []; + for (const placeType of placeTypes) { + const prefix = `${placeType}.`; + const context = contexts.find((candidate) => candidate.id?.startsWith(prefix)); + if (context) return context; } - const normalized = label.trim(); + return null; + } + + private normalizeFeatureLabel(feature: MapboxFeature | null): string | null { + if (!feature) return null; + + const rawLabel = feature.text || feature.place_name; + if (!rawLabel) return null; + + const normalized = rawLabel.trim(); + return normalized.length > 0 ? normalized : null; + } + + private normalizeContextLabel(context: MapboxContextFeature | null): string | null { + if (!context?.text) return null; + + const normalized = context.text.trim(); return normalized.length > 0 ? normalized : null; } + + private composeLabel(city: string | null, country: string | null): string | null { + if (city && country && city.toLowerCase() !== country.toLowerCase()) { + return `${city}, ${country}`; + } + + if (country) return country; + if (city) return city; + return null; + } + + private resolveFallbackPath(city: string | null, country: string | null): 'city_country' | 'country_only' | 'city_only' | 'none' { + if (city && country && city.toLowerCase() !== country.toLowerCase()) { + return 'city_country'; + } + + if (country) return 'country_only'; + if (city) return 'city_only'; + return 'none'; + } } diff --git a/src/app/shared/policies.content.ts b/src/app/shared/policies.content.ts index 91a0ec373..0db7ff312 100644 --- a/src/app/shared/policies.content.ts +++ b/src/app/shared/policies.content.ts @@ -24,7 +24,7 @@ export const POLICY_CONTENT: PolicyItem[] = [ 'Legal Basis: We process your data based on: (a) your consent for optional features like analytics, (b) contractual necessity to provide the service you subscribed to, and (c) our legitimate interest in maintaining service security.', 'Third-Party Processors: Your data is processed by: Google Cloud (hosting, EU region), Stripe (payments), and the fitness service providers you connect (Garmin, Suunto, COROS, Polar) solely to sync your activity data.' ], - checkboxLabel: 'I accept the Privacy Policy and acknowledge my data ownership rights.', + checkboxLabel: 'I have read and agree to the Privacy Policy and acknowledge my data ownership rights.', formControlName: 'acceptPrivacyPolicy' }, { @@ -38,7 +38,7 @@ export const POLICY_CONTENT: PolicyItem[] = [ 'Portability: You have the right to request an export of your personal data stored on our platform.', 'Retention: We retain your data while your account is active and has a valid subscription. For expired subscriptions, data may be permanently removed after a grace period of 30 days of inactivity.' ], - checkboxLabel: 'I acknowledge the Data Availability Policy.', + checkboxLabel: 'I have read and agree to the Data Availability Policy.', formControlName: 'acceptDataPolicy' }, { @@ -49,8 +49,8 @@ export const POLICY_CONTENT: PolicyItem[] = [ content: [ 'Under the General Data Protection Regulation (GDPR), you have the following rights:', '
  • Right of Access: You can request a copy of your personal data.
  • Right to Rectification: You can correct inaccurate personal data in your profile settings.
  • Right to Erasure: You can request deletion of your account and all associated data ("Right to be Forgotten").
  • Right to Restrict Processing: You can ask us to limit how we use your data.
  • Right to Data Portability: You can request your data in a structured, machine-readable format.
  • Right to Object: You can object to data processing based on legitimate interests.
  • Right to Withdraw Consent: You can withdraw consent at any time for optional processing (e.g., analytics).
', - '

Data Controller: Quantified Self
Contact: privacy@quantified-self.io
Data Location: European Union (Google Cloud EU region)
For privacy inquiries or to exercise your rights, contact us at the email above.

', - '

Supervisory Authority: If you believe your data protection rights have been violated, you have the right to lodge a complaint with your local Data Protection Authority. For users in Greece, this is the Hellenic Data Protection Authority (HDPA) at www.dpa.gr.

' + '

Data Controller: Quantified Self
Contact: privacy@quantified-self.io
Data Location: European Union (Google Cloud EU region)
For privacy inquiries or to exercise your rights, contact us at the email above.

', + '

Supervisory Authority: If you believe your data protection rights have been violated, you have the right to lodge a complaint with your local Data Protection Authority. For users in Greece, this is the Hellenic Data Protection Authority (HDPA) at www.dpa.gr.

' ], isGdpr: true }, @@ -66,7 +66,7 @@ export const POLICY_CONTENT: PolicyItem[] = [ 'Essential Cookies: Session cookies used to keep you logged in are strictly necessary for the service to function and do not require consent.', 'Withdraw Consent: You can withdraw your analytics consent at any time in your account settings.' ], - checkboxLabel: 'I consent to the collection of anonymized usage data for analytics.', + checkboxLabel: 'I have read and consent to the collection of anonymized usage data for analytics.', formControlName: 'acceptTrackingPolicy', isOptional: true }, @@ -82,7 +82,7 @@ export const POLICY_CONTENT: PolicyItem[] = [ 'Changes to Pricing: We reserve the right to change our pricing. Any price changes will be communicated to you in advance and will take effect at the start of the next billing cycle.', 'Data Deletion: Upon expiration or cancellation of a subscription, we may delete your stored data (including activities and tracks) after a grace period of 30 days of inactivity. It is your responsibility to export your data if you wish to keep it.' ], - checkboxLabel: 'I accept the Terms of Service and Subscription Policy.', + checkboxLabel: 'I have read and agree to the Terms of Service and Subscription Policy.', formControlName: 'acceptTos' }, { @@ -95,7 +95,7 @@ export const POLICY_CONTENT: PolicyItem[] = [ 'Unsubscribe Anytime: You can unsubscribe at any time from your account settings.', 'No Spam: We respect your inbox and only send relevant updates about the service.' ], - checkboxLabel: 'I would like to receive marketing emails and updates (optional).', + checkboxLabel: 'I have read and agree to receive marketing emails and updates.', formControlName: 'acceptMarketingPolicy', isOptional: true } diff --git a/src/app/utils/activity-edit.persistence.spec.ts b/src/app/utils/activity-edit.persistence.spec.ts new file mode 100644 index 000000000..627bc1733 --- /dev/null +++ b/src/app/utils/activity-edit.persistence.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { buildActivityEditWritePayload, buildActivityWriteData, buildEventWriteData } from './activity-edit.persistence'; + +describe('activity-edit.persistence', () => { + it('buildActivityWriteData strips streams and adds identity metadata', () => { + const event = { + getID: () => 'event-1', + startDate: new Date('2026-02-14T00:00:00.000Z'), + } as any; + + const activity = { + toJSON: () => ({ + creator: { name: 'Device A' }, + streams: [{ type: 'Pace', values: [1, 2, 3] }], + stats: {}, + }), + } as any; + + const result = buildActivityWriteData('user-1', event, activity); + + expect(result.streams).toBeUndefined(); + expect(result.eventID).toBe('event-1'); + expect(result.userID).toBe('user-1'); + expect(result.eventStartDate).toEqual(event.startDate); + expect(result.creator).toEqual({ name: 'Device A' }); + }); + + it('buildEventWriteData preserves original file metadata', () => { + const originalFiles = [{ path: 'users/user-1/events/event-1/original.fit', startDate: new Date('2026-02-14T00:00:00.000Z') }]; + const originalFile = originalFiles[0]; + + const event = { + toJSON: () => ({ name: 'Event Name' }), + originalFiles, + originalFile, + } as any; + + const result = buildEventWriteData(event); + + expect(result.name).toBe('Event Name'); + expect(result.originalFiles).toBe(originalFiles); + expect(result.originalFile).toBe(originalFile); + }); + + it('buildActivityEditWritePayload composes activity and event write payloads', () => { + const event = { + getID: () => 'event-1', + startDate: new Date('2026-02-14T00:00:00.000Z'), + toJSON: () => ({ title: 'Event Title' }), + originalFile: { path: 'users/user-1/events/event-1/original.fit', startDate: new Date('2026-02-14T00:00:00.000Z') }, + } as any; + + const activity = { + toJSON: () => ({ creator: { name: 'Device A' }, streams: [{ type: 'Pace', values: [1] }] }), + } as any; + + const result = buildActivityEditWritePayload('user-1', event, activity); + + expect(result.activityData.streams).toBeUndefined(); + expect(result.activityData.userID).toBe('user-1'); + expect(result.eventData.title).toBe('Event Title'); + expect(result.eventData.originalFile).toEqual(event.originalFile); + }); +}); diff --git a/src/app/utils/activity-edit.persistence.ts b/src/app/utils/activity-edit.persistence.ts new file mode 100644 index 000000000..644617c61 --- /dev/null +++ b/src/app/utils/activity-edit.persistence.ts @@ -0,0 +1,47 @@ +import { ActivityInterface, EventInterface } from '@sports-alliance/sports-lib'; + +export interface ActivityEditWritePayload { + activityData: any; + eventData: any; +} + +export function buildActivityWriteData(userID: string, event: EventInterface, activity: ActivityInterface): any { + const activityData = activity.toJSON() as any; + + // Activity documents should not persist streams; they come from source files/rehydration. + delete activityData.streams; + + activityData.eventID = event.getID(); + activityData.userID = userID; + if (event.startDate) { + activityData.eventStartDate = event.startDate; + } + + return activityData; +} + +export function buildEventWriteData(event: EventInterface): any { + const eventData = event.toJSON() as any; + const eventAny = event as any; + + // Preserve original file metadata across partial updates. + if (eventAny.originalFiles) { + eventData.originalFiles = eventAny.originalFiles; + } + if (eventAny.originalFile) { + eventData.originalFile = eventAny.originalFile; + } + + return eventData; +} + +export function buildActivityEditWritePayload( + userID: string, + event: EventInterface, + activity: ActivityInterface, +): ActivityEditWritePayload { + return { + activityData: buildActivityWriteData(userID, event, activity), + eventData: buildEventWriteData(event), + }; +} diff --git a/src/app/utils/app.event.utilities.spec.ts b/src/app/utils/app.event.utilities.spec.ts index d28237069..4512c4480 100644 --- a/src/app/utils/app.event.utilities.spec.ts +++ b/src/app/utils/app.event.utilities.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AppEventUtilities } from './app.event.utilities'; import { LoggerService } from '../services/logger.service'; import { TestBed } from '@angular/core/testing'; -import { ActivityTypes } from '@sports-alliance/sports-lib'; +import { ActivityTypes, EventUtilities } from '@sports-alliance/sports-lib'; describe('AppEventUtilities', () => { let mockActivity: any; @@ -110,6 +110,34 @@ describe('AppEventUtilities', () => { }); }); + describe('mergeEventsWithId', () => { + it('should clear generated merge description text', () => { + const mergedEvent = { + description: 'A merge of 2 or more activities ', + setID: vi.fn(), + }; + vi.spyOn(EventUtilities, 'mergeEvents').mockReturnValue(mergedEvent as any); + + const result = service.mergeEventsWithId([{} as any], () => 'merged-id'); + + expect(result).toBe(mergedEvent); + expect(mergedEvent.setID).toHaveBeenCalledWith('merged-id'); + expect(mergedEvent.description).toBe(''); + }); + + it('should keep non-generated descriptions unchanged', () => { + const mergedEvent = { + description: 'Evening ride with intervals', + setID: vi.fn(), + }; + vi.spyOn(EventUtilities, 'mergeEvents').mockReturnValue(mergedEvent as any); + + service.mergeEventsWithId([{} as any], () => 'merged-id'); + + expect(mergedEvent.description).toBe('Evening ride with intervals'); + }); + }); + describe('Exclusion Logic', () => { describe('shouldExcludeAscent', () => { it('should return true for AlpineSki', () => { diff --git a/src/app/utils/app.event.utilities.ts b/src/app/utils/app.event.utilities.ts index 115e4fe8c..ee6953da6 100644 --- a/src/app/utils/app.event.utilities.ts +++ b/src/app/utils/app.event.utilities.ts @@ -10,6 +10,8 @@ export class AppEventUtilities { constructor(private logger: LoggerService) { } + private static readonly GENERATED_MERGE_DESCRIPTION_PREFIX = 'a merge of 2 or more activit'; + /** * Merges multiple events into one with a guaranteed unique ID. * This prevents collision with source events when the deterministic ID generator @@ -22,9 +24,30 @@ export class AppEventUtilities { mergeEventsWithId(events: EventInterface[], idGenerator: () => string): EventInterface { const merged = EventUtilities.mergeEvents(events); merged.setID(idGenerator()); + this.clearGeneratedMergeDescription(merged); return merged; } + private clearGeneratedMergeDescription(event: EventInterface): void { + const mergedEvent = event as any; + const description = mergedEvent?.description; + if (typeof description !== 'string') { + return; + } + + const normalized = description.trim().toLowerCase(); + if (!normalized.startsWith(AppEventUtilities.GENERATED_MERGE_DESCRIPTION_PREFIX)) { + return; + } + + if (typeof mergedEvent.setDescription === 'function') { + mergedEvent.setDescription(''); + return; + } + + mergedEvent.description = ''; + } + /** * Enriches an activity with missing streams if possible * @param activity The activity to enrich diff --git a/src/app/utils/event-json-sanitizer.spec.ts b/src/app/utils/event-json-sanitizer.spec.ts index f9c393bdb..151ef79ee 100644 --- a/src/app/utils/event-json-sanitizer.spec.ts +++ b/src/app/utils/event-json-sanitizer.spec.ts @@ -268,6 +268,28 @@ describe('EventJSONSanitizer', () => { expect(unknownTypes).toContain(mockUnknownType); }); + it('should remove duplicate stream types from top-level stream arrays', () => { + setupMock(); + const json = { + streams: [ + { type: mockKnownType, values: [1, 2] }, + { type: mockKnownType, values: [3, 4] } + ] + }; + + const { sanitizedJson, issues } = EventJSONSanitizer.sanitize(json); + + expect(sanitizedJson.streams).toEqual([{ type: mockKnownType, values: [1, 2] }]); + expect(issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'malformed_event_payload', + location: 'streams', + path: 'streams[1]', + type: mockKnownType + }) + ])); + }); + it('should drop known event types with malformed payloads', () => { setupMock([], (type, payload) => type === mockKnownType && payload === undefined); const json = { diff --git a/src/app/utils/event-json-sanitizer.ts b/src/app/utils/event-json-sanitizer.ts index 181be9339..bd35fcedf 100644 --- a/src/app/utils/event-json-sanitizer.ts +++ b/src/app/utils/event-json-sanitizer.ts @@ -116,6 +116,7 @@ export class EventJSONSanitizer { if (Array.isArray(streams)) { // It's an array of StreamJSONInterface + const seenTypes = new Set(); return streams.filter((stream, streamIndex) => { const type = stream.type; let dataClass; @@ -136,6 +137,19 @@ export class EventJSONSanitizer { }); return false; // Remove this stream } + + if (seenTypes.has(type)) { + issues.push({ + kind: 'malformed_event_payload', + location: 'streams', + path: `${pathPrefix}[${streamIndex}]`, + type, + reason: 'Removed duplicate stream data type from array payload' + }); + return false; + } + + seenTypes.add(type); return true; }); } else if (typeof streams === 'object' && streams !== null) { diff --git a/src/app/utils/event-live-reconcile.spec.ts b/src/app/utils/event-live-reconcile.spec.ts new file mode 100644 index 000000000..8b8bfb673 --- /dev/null +++ b/src/app/utils/event-live-reconcile.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { reconcileEventDetailsLiveUpdate } from './event-live-reconcile'; + +const createActivity = ( + id: string, + streams: any[] = [], + streamGetter: 'getStreams' | 'getAllStreams' = 'getStreams', +): any => { + let currentStreams = [...streams]; + const getter = () => currentStreams; + return { + getID: () => id, + ...(streamGetter === 'getStreams' ? { getStreams: getter } : { getAllStreams: getter }), + clearStreams: () => { + currentStreams = []; + }, + addStreams: (nextStreams: any[]) => { + currentStreams = [...nextStreams]; + }, + }; +}; + +const createEvent = (activities: any[]): any => ({ + getActivities: () => activities, +}); + +describe('event-live-reconcile', () => { + it('preserves selected activity IDs and existing streams when activity IDs match', () => { + const currentActivity = createActivity('a1', [{ type: 'Speed', values: [1, 2, 3] }]); + const incomingActivity = createActivity('a1'); + const currentEvent = createEvent([currentActivity]); + const incomingEvent = createEvent([incomingActivity]); + + const result = reconcileEventDetailsLiveUpdate(currentEvent, incomingEvent, ['a1']); + + expect(result.needsFullReload).toBe(false); + expect(result.selectedActivityIDs).toEqual(['a1']); + expect(incomingActivity.getStreams()).toEqual([{ type: 'Speed', values: [1, 2, 3] }]); + }); + + it('flags full reload when activity IDs changed', () => { + const currentEvent = createEvent([createActivity('a1')]); + const incomingEvent = createEvent([createActivity('a2')]); + + const result = reconcileEventDetailsLiveUpdate(currentEvent, incomingEvent, ['a1']); + + expect(result.needsFullReload).toBe(true); + expect(result.selectedActivityIDs).toEqual([]); + }); + + it('returns incoming event directly when there is no current event', () => { + const incomingEvent = createEvent([createActivity('a1')]); + + const result = reconcileEventDetailsLiveUpdate(null, incomingEvent, ['a1']); + + expect(result.reconciledEvent).toBe(incomingEvent); + expect(result.needsFullReload).toBe(false); + expect(result.selectedActivityIDs).toEqual(['a1']); + }); + + it('preserves streams when source activity exposes getAllStreams', () => { + const currentActivity = createActivity('a1', [{ type: 'LatitudeDegrees', values: [1] }], 'getAllStreams'); + const incomingActivity = createActivity('a1'); + const currentEvent = createEvent([currentActivity]); + const incomingEvent = createEvent([incomingActivity]); + + const result = reconcileEventDetailsLiveUpdate(currentEvent, incomingEvent, ['a1']); + + expect(result.needsFullReload).toBe(false); + expect(incomingActivity.getStreams()).toEqual([{ type: 'LatitudeDegrees', values: [1] }]); + }); +}); diff --git a/src/app/utils/event-live-reconcile.ts b/src/app/utils/event-live-reconcile.ts new file mode 100644 index 000000000..c45f8fb2b --- /dev/null +++ b/src/app/utils/event-live-reconcile.ts @@ -0,0 +1,80 @@ +import { ActivityInterface } from '@sports-alliance/sports-lib'; +import { AppEventInterface } from '../../../functions/src/shared/app-event.interface'; + +export interface EventLiveReconcileResult { + reconciledEvent: AppEventInterface; + selectedActivityIDs: string[]; + needsFullReload: boolean; +} + +function filterSelectedIDsByAvailableActivities(activities: ActivityInterface[], selectedActivityIDs: string[]): string[] { + if (!selectedActivityIDs?.length) { + return []; + } + const availableIDs = new Set((activities || []).map((activity) => activity.getID())); + return selectedActivityIDs.filter((activityID) => availableIDs.has(activityID)); +} + +function preserveActivityStreams(sourceActivity: ActivityInterface, targetActivity: ActivityInterface): void { + const sourceGetStreams = (sourceActivity as any)?.getAllStreams ?? (sourceActivity as any)?.getStreams; + const targetClearStreams = (targetActivity as any)?.clearStreams; + const targetAddStreams = (targetActivity as any)?.addStreams; + + if ( + typeof sourceGetStreams !== 'function' + || typeof targetClearStreams !== 'function' + || typeof targetAddStreams !== 'function' + ) { + return; + } + + const streams = sourceGetStreams.call(sourceActivity) || []; + targetClearStreams.call(targetActivity); + targetAddStreams.call(targetActivity, streams); +} + +export function reconcileEventDetailsLiveUpdate( + currentEvent: AppEventInterface | null, + incomingEvent: AppEventInterface, + selectedActivityIDs: string[], +): EventLiveReconcileResult { + const incomingActivities = incomingEvent?.getActivities?.() || []; + + if (!currentEvent) { + return { + reconciledEvent: incomingEvent, + selectedActivityIDs: filterSelectedIDsByAvailableActivities(incomingActivities, selectedActivityIDs), + needsFullReload: false, + }; + } + + const currentActivities = currentEvent.getActivities() || []; + const currentActivitiesByID = new Map(currentActivities.map((activity) => [activity.getID(), activity])); + const currentActivityIDs = currentActivities.map((activity) => activity.getID()); + const incomingActivityIDs = incomingActivities.map((activity) => activity.getID()); + + const haveSameActivitySet = currentActivityIDs.length === incomingActivityIDs.length + && incomingActivityIDs.every((activityID) => currentActivitiesByID.has(activityID)); + + if (!haveSameActivitySet) { + return { + reconciledEvent: incomingEvent, + selectedActivityIDs: filterSelectedIDsByAvailableActivities(incomingActivities, selectedActivityIDs), + needsFullReload: true, + }; + } + + incomingActivities.forEach((incomingActivity) => { + const currentActivity = currentActivitiesByID.get(incomingActivity.getID()); + if (!currentActivity) { + return; + } + preserveActivityStreams(currentActivity, incomingActivity); + }); + + return { + reconciledEvent: incomingEvent, + selectedActivityIDs: filterSelectedIDsByAvailableActivities(incomingActivities, selectedActivityIDs), + needsFullReload: false, + }; +} diff --git a/src/assets/logos/antigravity.svg b/src/assets/logos/antigravity.svg deleted file mode 100644 index e2e97d792..000000000 --- a/src/assets/logos/antigravity.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c965f6299..2e72d2b18 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -14,7 +14,7 @@ export const environment = { beta: false, localhost: true, forceAnalyticsCollection: true, - useAuthEmulator: false, // Set to true to use Firebase Auth Emulator + useAuthEmulator: false, // Use Firebase Auth Emulator in local development firebase: { apiKey: 'AIzaSyBdR4jbTKmm_P4L7t26IFAgFn6Eoo02aU0', authDomain: 'quantified-self-io.firebaseapp.com', diff --git a/src/styles.scss b/src/styles.scss index e83fed744..43f60cc10 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,7 +6,8 @@ @use 'styles/search'; @use 'styles/segmented-buttons'; @use 'styles/google-maps'; -@use 'styles/glass'; +@use 'styles/panels'; +@use 'styles/icons'; // Include the common styles for Angular Material. We include this here so that you only // have to load a single css file for Angular Material in your app. @@ -452,7 +453,7 @@ mat-table { } /* Mobile Responsive */ -@media (max-width: 600px) { +@media (max-width: 599.98px) { .app-header-card { flex-direction: column; align-items: flex-start; @@ -625,11 +626,15 @@ mat-table { } /* Reusable Custom Scrollbar */ -.qs-scrollbar { +@mixin qs-scrollbar-skin { + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 36%, transparent) transparent; + &::-webkit-scrollbar { - height: 8px; + height: 10px; /* Horizontal scrollbar height */ - width: 8px; + width: 10px; /* Vertical scrollbar width */ } @@ -638,23 +643,62 @@ mat-table { } &::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - border-radius: 4px; + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; } &::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.3); + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 46%, transparent); + } + + &::-webkit-scrollbar-thumb:active { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 56%, transparent); } } +.qs-scrollbar { + @include qs-scrollbar-skin; +} + +/* Also style shell-level scroll containers (their scrollbar is often the visible one) */ +.app-sidenav-container .mat-drawer-content, +.app-sidenav-container .mat-drawer-inner-container { + @include qs-scrollbar-skin; +} + /* Dark theme support for global scrollbar */ .dark-theme .qs-scrollbar { + scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 44%, transparent) transparent; + + &::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 40%, transparent); + } + + &::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 56%, transparent); + } + + &::-webkit-scrollbar-thumb:active { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 66%, transparent); + } +} + +.dark-theme .app-sidenav-container .mat-drawer-content, +.dark-theme .app-sidenav-container .mat-drawer-inner-container { + scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 44%, transparent) transparent; + &::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.2); + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 40%, transparent); } &::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.3); + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 56%, transparent); + } + + &::-webkit-scrollbar-thumb:active { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 66%, transparent); } } @@ -662,7 +706,7 @@ mat-table { Style the sheet once via the Material host class to avoid double-stacking when a panelClass is also present. */ .mat-bottom-sheet-container { - @extend .glass-card; + @extend .qs-glass-card-panel; @extend .qs-scrollbar; --mat-bottom-sheet-container-background-color: transparent; --mat-bottom-sheet-container-shape: 20px; @@ -697,7 +741,7 @@ mat-table { } } -@media (max-width: 599px) { +@media (max-width: 599.98px) { .cdk-overlay-pane.qs-bottom-sheet-container .mat-bottom-sheet-container { min-width: 100%; width: 100%; @@ -718,9 +762,9 @@ mat-table { .mat-mdc-dialog-container, .mat-mdc-dialog-surface { + @extend .qs-glass-card-panel; + --qs-glass-panel-blur: 16px; background: var(--mat-sys-surface) !important; - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 16px !important; box-shadow: @@ -747,7 +791,7 @@ mat-table { } } -@media (max-width: 600px) { +@media (max-width: 599.98px) { .qs-dialog-container { .mat-mdc-dialog-title { padding: 20px 16px 6px !important; @@ -822,17 +866,32 @@ tr.mat-row:focus, } /* ========================================================================== - Global Menu Form Layouts - Ensures configuration forms inside mat-menu look properly spaced. + Global Menu Panel Styles + Shared panel classes for action menus and config menus. ========================================================================== */ -/* Panel Overrides */ +/* Base menu panel style applied globally via MAT_MENU_DEFAULT_OPTIONS */ +.mat-mdc-menu-panel.qs-menu-panel { + @extend .qs-glass-card-panel; + background: var(--mat-sys-surface) !important; + min-width: 180px !important; + max-width: min(360px, calc(100vw - 24px)) !important; +} + +/* Form-heavy menu panels */ +.mat-mdc-menu-panel.qs-menu-panel-form, .mat-mdc-menu-panel.qs-config-menu { - max-width: none !important; - min-width: 300px !important; + min-width: 280px !important; + max-width: min(380px, calc(100vw - 24px)) !important; +} + +.mat-mdc-menu-panel.qs-menu-panel .mat-mdc-menu-content { + @extend .qs-scrollbar; + padding: 6px 0; } /* Section/Item Layout for menus used for settings */ +.qs-menu-panel-form, .qs-config-menu { section[mat-menu-item] { height: auto !important; @@ -856,3 +915,48 @@ tr.mat-row:focus, display: block; } } + +.mat-mdc-select-panel.qs-config-submenu { + @extend .qs-glass-card-panel; + background: var(--mat-sys-surface) !important; + border-radius: 12px !important; + padding: 4px 0 !important; + max-width: min(320px, calc(100vw - 24px)) !important; +} + +.mat-mdc-select-panel.qs-config-submenu-compact { + min-width: 104px !important; + width: 104px !important; + max-width: 104px !important; +} + +.mat-mdc-select-panel.qs-config-submenu-compact .mat-mdc-option { + justify-content: center; + text-align: center; +} + +.mat-mdc-select-panel.qs-config-submenu-compact .mdc-list-item__primary-text { + width: 100%; + text-align: center; +} + +/* ========================================================================== + Global Button Ripple Shape + Ensure ripple/state layers follow the effective button radius even when + components override button border-radius locally. + ========================================================================== */ +.mdc-button .mdc-button__ripple, +.mdc-button .mat-mdc-button-persistent-ripple, +.mdc-button .mat-mdc-button-persistent-ripple::before, +.mdc-button .mat-ripple-element, +.mdc-icon-button .mat-mdc-button-persistent-ripple, +.mdc-icon-button .mat-mdc-button-persistent-ripple::before, +.mdc-icon-button .mat-ripple-element { + border-radius: inherit !important; +} + +/* Clip animated ripple to the button's rounded corners. */ +.mdc-button .mat-mdc-button-persistent-ripple, +.mdc-icon-button .mat-mdc-button-persistent-ripple { + overflow: hidden; +} diff --git a/src/styles/_breakpoints.scss b/src/styles/_breakpoints.scss index 0239af7b5..c74c97fd5 100644 --- a/src/styles/_breakpoints.scss +++ b/src/styles/_breakpoints.scss @@ -14,6 +14,12 @@ $bp-medium-max: 1279.98px; // Matches AppBreakpoints.Medium $bp-large-min: 1280px; $bp-large-max: 1919.98px; // Matches AppBreakpoints.Large $bp-xlarge-min: 1920px; // Matches AppBreakpoints.XLarge +$bp-max-480: 480px; // Matches AppBreakpoints.Max480 +$bp-max-640: 640px; // Matches AppBreakpoints.Max640 +$bp-max-768: 768px; // Matches AppBreakpoints.Max768 +$bp-max-900: 900px; // Matches AppBreakpoints.Max900 +$bp-max-1024: 1024px; // Matches AppBreakpoints.Max1024 +$bp-min-768: 768px; // Matches AppBreakpoints.Min768 // Convenience mixins @mixin xsmall { @@ -64,9 +70,45 @@ $bp-xlarge-min: 1920px; // Matches AppBreakpoints.XLarge } } +@mixin max-480 { + @media only screen and (max-width: $bp-max-480) { + @content; + } +} + +@mixin max-640 { + @media only screen and (max-width: $bp-max-640) { + @content; + } +} + +@mixin max-768 { + @media only screen and (max-width: $bp-max-768) { + @content; + } +} + +@mixin max-900 { + @media only screen and (max-width: $bp-max-900) { + @content; + } +} + +@mixin max-1024 { + @media only screen and (max-width: $bp-max-1024) { + @content; + } +} + +@mixin min-768 { + @media only screen and (min-width: $bp-min-768) { + @content; + } +} + // Handset or tablet portrait (anything below medium) @mixin handset-or-tablet-portrait { @media only screen and (max-width: $bp-small-max) { @content; } -} \ No newline at end of file +} diff --git a/src/styles/_forms.scss b/src/styles/_forms.scss index 7524c07ae..05133eb50 100644 --- a/src/styles/_forms.scss +++ b/src/styles/_forms.scss @@ -1,3 +1,5 @@ +@use 'breakpoints' as bp; + // Global Form Styles // Usage: Wrap your form in a container with class .qs-form-container if you want centering on the page. // Add class .qs-form to the
element itself. @@ -82,7 +84,7 @@ margin-top: 1rem; // If we want buttons to be full width on mobile but auto on desktop - @media (max-width: 600px) { + @include bp.xsmall { flex-direction: column; button { @@ -152,4 +154,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/styles/_glass.scss b/src/styles/_glass.scss deleted file mode 100644 index 35ef0c74c..000000000 --- a/src/styles/_glass.scss +++ /dev/null @@ -1,26 +0,0 @@ -/* ========================================================================== - Global Glassmorphism Card Style - Centralized so any component (dialogs, sheets, cards) can reuse it. - ========================================================================== */ - -.glass-card { - background: var(--mat-sys-surface) !important; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--mat-app-outline-variant); - border-radius: 20px !important; /* Force border radius */ - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.05), - 0 2px 4px -1px rgba(0, 0, 0, 0.03), - 0 10px 15px -3px rgba(0, 0, 0, 0.05) !important; /* Override material elevation */ - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -/* Dark Theme Overrides for Glass Card */ -.dark-theme { - .glass-card { - /* Background handled by variable */ - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2) !important; - } -} diff --git a/src/styles/_google-maps.scss b/src/styles/_google-maps.scss index 4da38d4d0..9311f00cb 100644 --- a/src/styles/_google-maps.scss +++ b/src/styles/_google-maps.scss @@ -9,6 +9,7 @@ background-color: transparent !important; box-shadow: none !important; padding: 0 !important; + max-width: calc(100vw - 24px) !important; } /* Hide the little triangle pointer */ @@ -18,8 +19,10 @@ /* Hide the scroll container background */ .gm-style-iw-d { - overflow: hidden !important; + overflow: auto !important; background-color: transparent !important; + max-height: min(60vh, 420px) !important; + -webkit-overflow-scrolling: touch; } /* Hide the default Google Maps close button (we implement our own) */ diff --git a/src/styles/_icons.scss b/src/styles/_icons.scss new file mode 100644 index 000000000..b2a3e234a --- /dev/null +++ b/src/styles/_icons.scss @@ -0,0 +1,5 @@ +.icon-mirror-x { + display: inline-block; + transform: scaleX(-1); + transform-origin: center; +} diff --git a/src/styles/_panels.scss b/src/styles/_panels.scss new file mode 100644 index 000000000..6b4b6e86c --- /dev/null +++ b/src/styles/_panels.scss @@ -0,0 +1,38 @@ +/* ========================================================================== + Shared Glass Panel Primitive + Canonical class: .qs-glass-card-panel + Backward-compatible alias: .glass-card + ========================================================================== */ + +:root { + --qs-glass-panel-bg: var(--mat-app-surface, var(--mat-sys-surface)); + --qs-glass-panel-border: var(--mat-app-outline-variant); + --qs-glass-panel-radius: 20px; + --qs-glass-panel-blur: 12px; + --qs-glass-panel-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.05), + 0 2px 4px -1px rgba(0, 0, 0, 0.03), + 0 10px 15px -3px rgba(0, 0, 0, 0.05); +} + +.qs-glass-card-panel, +.glass-card { + background: var(--qs-glass-panel-bg) !important; + backdrop-filter: blur(var(--qs-glass-panel-blur)); + -webkit-backdrop-filter: blur(var(--qs-glass-panel-blur)); + border: 1px solid var(--qs-glass-panel-border); + border-radius: var(--qs-glass-panel-radius) !important; + box-shadow: var(--qs-glass-panel-shadow) !important; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.dark-theme { + --qs-glass-panel-bg: var(--mat-app-surface, rgba(30, 30, 30, 0.8)); + + .qs-glass-card-panel, + .glass-card { + background: var(--qs-glass-panel-bg) !important; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2) !important; + } +}