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
-
- Email
-
- 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 `` instead of ``
-- ❌ Creating custom input wrappers instead of `mat-form-field`
-- ❌ Manual ripple effects instead of `matRipple`
-- ❌ Custom dropdowns instead of `mat-select` or `mat-menu`
-- ❌ Hardcoded colors instead of using theme variables
-- ❌ Ignoring accessibility attributes that Material provides
-- ❌ Using `::ng-deep` excessively to fight Material styles instead of using density/typography APIs
-
-### 9. When Custom Components Are Acceptable
-
-Custom components are justified when:
-- Angular Material genuinely doesn't provide the functionality (e.g., complex scheduler, kanban board)
-- The design requirements deviate significantly from Material Design guidelines (though theming should be tried first)
-- Performance requires a specialized implementation (e.g., virtual scrolling with complex custom rendering not supported by CDK)
-
-## Review Format
-
-Structure your review with:
-
-1. **Component Audit**: List custom components found and their Material alternatives
-2. **Quick Wins**: Easy replacements with immediate benefits
-3. **Theming Issues**: Problems with theme/color usage
-4. **Code Examples**: Show before/after comparisons
-
-## Suggestions Format
-
-For each custom component, provide:
-- **Current Implementation**: Brief description
-- **Material Alternative**: Specific component to use
-- **Benefits**: Why Material component is better (consistency, a11y, maintenance)
-- **Migration**: Code example showing how to migrate
-
-## Context Awareness
-
-- Check the project's Angular Material version
-- Consider existing Material usage patterns in the codebase
-- Respect project-specific theme configurations
-
-## Balanced Approach
-
-You will be practical and pragmatic:
-- Don't force Material where custom is genuinely better
-- Consider migration effort vs benefits
-- Prioritize high-impact replacements first
-- Focus on maintainability and consistency
-
-## Context7 Usage
-
-- **Documentation Lookup**: When you need to verify component APIs, theming mixins, or migration guides for Angular Material v20, **ALWAYS** use the `context7` MCP server.
-- **Library ID**: Use `mcp0_resolve-library-id` with query "angular material" to get the correct library ID before fetching docs.
-- **Theming**: Use `context7` to look up the latest Material Design 3 theming guidelines if you are unsure about a specific mixin or variable.
-
-## MOST IMPORTANT
-
-- We have a custom theme at src/custom-theme.scss if you are styling components there is where things should happen.
-- Awlays poll https://v18.material.angular.dev/guide/theming for new guidelines and https://v18.material.angular.dev/guide/theming#theming-and-style-encapsulation
-
-10. **Official First**:
- * **ALWAYS** check official Angular Material documentation for built-in solutions before creating custom wrappers (e.g., `div` containers) or utility classes.
- * If a documented pattern exists (e.g., for responsive tables or layouts), use it instead of ad-hoc HTML/CSS.
+Use this rule when implementing or reviewing Angular Material UI.
+
+## Apply This Rule
+- Building or refactoring UI components with Angular Material
+- Replacing custom UI structures with Material components
+- Reviewing theme-token and Material API usage
+
+## Do Not Apply This Rule
+- Backend-only, security-only, or testing-only tasks
+- Non-UI architecture decisions
+
+## Core Guidance
+- Prefer native Material components before creating custom UI wrappers.
+- Use Material component structure (`mat-card-header`, `mat-card-title`, etc.) instead of ad-hoc div layouts.
+- Keep styles token-based (`--mat-sys-*`) and avoid hardcoded colors.
+- Use Material typography tokens for text styles.
+- Use global dialog conventions; avoid per-dialog panel classes unless documented.
+
+## Common Component Mapping
+- Layout: `MatToolbar`, `MatSidenav`, `MatCard`, `MatDivider`
+- Inputs: `MatFormField`, `MatInput`, `MatSelect`, `MatDatepicker`
+- Data display: `MatTable`, `MatList`, `MatTree`, `MatIcon`
+- Feedback: `MatDialog`, `MatSnackBar`, `MatProgressBar`, `MatSpinner`
+- Navigation: `MatMenu`, `MatTabs`, `MatStepper`, `MatPaginator`
+
+## Review Output
+- List mismatches between custom UI and available Material components.
+- Provide direct replacement suggestions.
+- Flag theme-token violations and show compliant alternatives.
diff --git a/.agent/rules/angular-typescript-expert.md b/.agent/rules/angular-typescript-expert.md
index 7003b2809..b745a4070 100644
--- a/.agent/rules/angular-typescript-expert.md
+++ b/.agent/rules/angular-typescript-expert.md
@@ -1,148 +1,41 @@
---
trigger: model_decision
-description: Use this rule when doing angular or typescript tasks
+description: Use for Angular and TypeScript implementation tasks.
---
-You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.
-
-## TypeScript Best Practices
-
-- Use strict type checking
-- Prefer type inference when the type is obvious
-- Avoid the `any` type; use `unknown` when type is uncertain
-
-## Angular Best Practices
-
-- Always use standalone components over NgModules
-- Must NOT set `standalone: true` inside Angular decorators. It's the default.
-- Use signals for state management
-# EventRoute
-
-This project is an Angular application for visualizing event routes.
-
-## Development Server
-
-Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
-- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
-- Use `NgOptimizedImage` for all static images.
- - `NgOptimizedImage` does not work for inline base64 images.
-
-## Firebase & Firestore
-
-This application uses Firebase for backend services:
-
-- **Firebase Hosting**: Deployed at `https://raceevents.web.app`
-- **Firestore Database**: NoSQL database for storing races and user data
-- **Firebase Authentication**: Google Sign-In for admin users
-- **Project ID**: `raceevents-71704`
-
-### Firestore Collections
-
-- `races`: Race event data (public read, admin-only write)
-- `users`: User profiles with role-based access (admin/superadmin)
-
-### Security Rules
-
-- Only users with `role: 'admin'` can create/update/delete races
-- Superadmin users (Firestore admins) have full access
-- See `firestore.rules` for complete security configuration
-
-### Firebase Configuration
-
-Firebase is initialized in `src/app/app.config.ts` using Angular Fire:
-- `provideFirebaseApp()` - Firebase app initialization
-- `provideFirestore()` - Firestore database
-- `provideAuth()` - Firebase Authentication (when implemented)
-
-Configuration is stored in `src/environments/environment.ts`
-
-## Components
-
-- Keep components small and focused on a single responsibility
-- Use `input()` and `output()` functions instead of decorators
-- Use `computed()` for derived state
-- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
-- Prefer inline templates for small components
-- Prefer Reactive forms instead of Template-driven ones
-- Do NOT use `ngClass`, use `class` bindings instead
-- Do NOT use `ngStyle`, use `style` bindings instead
-
-## State Management
-
-- Use signals for local component state
-- Use `computed()` for derived state
-- Keep state transformations pure and predictable
-- Do NOT use `mutate` on signals, use `update` or `set` instead
-
-## Templates
-
-- Keep templates simple and avoid complex logic
-- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
-- Use the async pipe to handle observables
-- **ANTI-PATTERN**: Do NOT use `| async` on method calls in templates (e.g., `[href]="getUrl() | async"`).
- - This causes infinite loops and performance issues because the method returns a new Observable on every change detection.
- - **Solution**: Use signals or pre-calculated Observables instead.
-
-## Services
-
-- Design services around a single responsibility
-- Use the `providedIn: 'root'` option for singleton services
-- Use the `inject()` function instead of constructor injection
-
-## UI / Styling
-
-- **ALWAYS use Angular Material components** for UI elements - this is mandatory
-- **NEVER use raw HTML elements** if a Material component exists (e.g., use `mat-button` instead of ``, `mat-list-item` instead of ``)
-- Common Material components to use:
- - Buttons: `mat-button`, `mat-raised-button`, `mat-icon-button`, `mat-fab`
- - Lists: `mat-list`, `mat-list-item`, `mat-nav-list`
- - Forms: `mat-form-field`, `mat-input`, `mat-select`, `mat-checkbox`
- - Layout: `mat-toolbar`, `mat-sidenav`, `mat-card`
- - Feedback: `mat-progress-spinner`, `mat-snack-bar`, `mat-dialog`
-- **Theme Support**: Always write CSS that supports both light and dark themes
- - Use CSS variables (custom properties) for colors instead of hardcoded values
- - Define theme-specific variables in the theme file (e.g., `--bg-primary`, `--text-primary`, `--accent-color`)
- - Provide fallback values for CSS variables: `var(--bg-primary, #0a0a0a)`
- - Avoid theme-specific logic in component styles; keep styles theme-agnostic
- - Test components in both light and dark modes
-- **Responsive Layouts**:
- - Use `BreakpointObserver` for logic.
- - **Mobile Breakpoint**: `max-width: 768px`
- - **Container Width**: `max-width: 1200px`
-
-## AI Agents
-
-This project includes specialized AI agents for code review and quality assurance. These agents can be invoked via prompts to provide expert analysis check them one by one at `.ai/agents`
-
-### Code Quality & Architecture
-- **code-quality-analyzer**: Comprehensive code quality analysis including bug detection, performance optimization, and best practices compliance
-- **tech-lead-architect**: Senior technical leadership perspective on architecture, system design, scalability, and technical debt assessment
-- **code-refactoring-specialist**: Expert guidance on refactoring code for better maintainability and performance. Use this agent when I ask you to refactor something.
-- **security-reviewer**: Security vulnerability assessment and secure coding practices. Use this agent when I ask you to check for security vulnerabilities.
-
-### UI/UX & Design
-- **ui-reviewer**: Visual design consistency, cohesion, and professional polish evaluation. Use this agent when I ask you about user interface tasks.
-- **ux-reviewer**: User experience analysis, usability assessment, and interaction design review. Use this agent when I ask you about user interface tasks.
-- **mobile-first-expert**: Mobile-first design and responsive implementation guidance. Use this agent when I ask you about user interface tasks.
-- **angular-material-expert**: Expert guidance on using Angular Material components, theming, and best practices. Use this agent when I ask you about user interface tasks.
-
-### Testing & Review
-- **test-automation-engineer**: Test strategy, coverage analysis, and automated testing implementation
-- **code-review-expert**: Thorough code review with focus on maintainability and team standards
-- **technical-researcher**: Research and evaluation of technologies, libraries, and implementation approaches
-
-**Usage**: Mention the agent name in your prompt when you need specialized analysis (e.g., "Use the ui-reviewer to check the new dashboard design" or "Have the security-reviewer analyze the authentication flow")
-
-## Context7 Usage
-
-- **Angular Core**: Use `context7` to look up documentation for Angular v20 features, especially Signals, Control Flow, and Standalone Components.
-- **Firebase**: Use `context7` for Firebase v11 SDK documentation (Firestore, Auth, Storage).
-- **Mapbox**: Use `context7` to find documentation for Mapbox GL JS v3 and `@types/mapbox-gl`.
-- **PrimeNG**: Use `context7` for PrimeNG v17+ component documentation if used.
-- **Library Resolution**: Always use `mcp_resolve-library-id` first to find the correct library ID (e.g., "mapbox-gl", "firebase", "angular").
-
-## General Coding Rules
-
-- **Official First**:
- - **ALWAYS** check official documentation (Angular, Material, TypeScript) for built-in solutions before designing custom ones.
- - If a documented pattern exists, prefer it over custom implementations.
+Use this rule for day-to-day Angular and TypeScript implementation work.
+
+## Apply This Rule
+- Angular component, service, directive, pipe, or template changes
+- TypeScript refactors and API design
+
+## Do Not Apply This Rule
+- Security-only review tasks (use `security-reviewer`)
+- UX-only audits (use `ux-ui`)
+
+## TypeScript
+- Prefer explicit types at API boundaries; use inference inside function bodies.
+- Avoid `any`; use `unknown` and narrow with guards.
+- Handle `null` and `undefined` explicitly.
+
+## Angular
+- Prefer standalone APIs and modern control flow (`@if`, `@for`, `@switch`).
+- Use `input()` and `output()` for component contracts.
+- Prefer `inject()` for new dependency injection code.
+- Keep `ChangeDetectionStrategy.OnPush` unless there is a clear reason not to.
+- Use `host` metadata instead of `@HostBinding`/`@HostListener`.
+
+## Reactivity
+- Prefer Signals for local and service state.
+- Use Observables for async streams and external event sources.
+- Observables end with `$`; Signals do not.
+- Do not call methods that create Observables directly from template bindings.
+
+## Templates and Styling
+- Keep template logic simple; move heavy logic to component code.
+- Prefer class/style bindings over `ngClass` and `ngStyle` when feasible.
+- Use `NgOptimizedImage` for static image assets.
+- Follow project Material/theme conventions.
+
+## Validation
+- Run relevant tests after modifications.
diff --git a/.agent/rules/breakpoints.md b/.agent/rules/breakpoints.md
index a2f936daf..fed0153ec 100644
--- a/.agent/rules/breakpoints.md
+++ b/.agent/rules/breakpoints.md
@@ -1,11 +1,20 @@
---
trigger: model_decision
-description: Use this rule when working with responsive design and media queries.
+description: Use for responsive design decisions and media-query standardization.
---
+Use this rule when changing responsive layout behavior.
+
+## Apply This Rule
+- CSS/SCSS media queries
+- Responsive TypeScript logic (`BreakpointObserver` or constants)
+
+## Do Not Apply This Rule
+- Non-responsive UI changes
+
## Standard Breakpoints
-Use these Angular Material-aligned breakpoint values ONLY:
+Use these project breakpoint values only:
| Name | Max-Width | Min-Width | Use Case |
|------|-----------|-----------|----------|
@@ -15,35 +24,10 @@ Use these Angular Material-aligned breakpoint values ONLY:
| Large | 1919px | 1280px | Desktops |
## Rules
+1. Do not introduce arbitrary breakpoints.
+2. Import constants from `@shared/constants/breakpoints` in TypeScript.
+3. Keep CSS media queries aligned to the same values.
-1. **Use Standard Values Only**:
- - **DO NOT** use arbitrary values like 480px, 650px, 768px, 900px
- - **ALWAYS** use the values from the table above
-
-2. **Import Constants for TypeScript**:
- ```typescript
- import { Breakpoints, MediaQueries } from '@shared/constants/breakpoints';
- ```
-
-3. **CSS Media Query Patterns**:
- ```css
- /* Phone only */
- @media (max-width: 599px) { }
-
- /* Tablet and below */
- @media (max-width: 959px) { }
-
- /* Tablet and above */
- @media (min-width: 600px) { }
-
- /* Desktop and above */
- @media (min-width: 960px) { }
- ```
-
-4. **Reference**:
- - TypeScript constants: `src/app/constants/breakpoints.ts`
- - CSS documentation: `:root` block in `src/styles.css`
-
-## Checklist
-- [ ] Am I using standard breakpoint values?
-- [ ] Does the breakpoint match the device target?
+## Source of Truth
+- `src/app/constants/breakpoints.ts`
+- `src/styles/_breakpoints.scss`
diff --git a/.agent/rules/code-quality-analyzer copy.md b/.agent/rules/code-quality-analyzer copy.md
deleted file mode 100644
index 2018997c4..000000000
--- a/.agent/rules/code-quality-analyzer copy.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-name: code-quality-analyzer
-description: Use this agent when you need comprehensive analysis of code quality, including bug detection, performance optimization opportunities, and best practices compliance. This agent examines code for potential issues, evaluates maintainability, and provides actionable feedback for improvement. Examples: Context: The user has just implemented a new feature and wants to ensure code quality before merging. user: "I've implemented the user authentication module" assistant: "I'll analyze the authentication module code for quality issues" Since new code has been written, use the Task tool to launch the code-quality-analyzer agent to review for bugs, performance, and best practices. assistant: "Let me use the code-quality-analyzer agent to examine this implementation" Context: The user is refactoring existing code and wants to verify improvements. user: "I've refactored the data processing pipeline for better performance" assistant: "I'll analyze the refactored pipeline code" After refactoring, use the code-quality-analyzer agent to verify the changes improve quality and don't introduce new issues. Context: The user has completed a module and wants a quality check. user: "The payment processing module is complete" assistant: "Let me analyze the payment processing module for quality" With a completed module, use the code-quality-analyzer agent to ensure it meets quality standards before deployment.
----
-
-You are an expert software engineer specializing in code quality analysis. You have deep expertise in identifying bugs, performance bottlenecks, and violations of best practices across multiple programming languages and paradigms.
-
-Your analysis approach:
-
-1. **Bug Detection**: You systematically examine code for:
- - Logic errors and edge cases
- - Null/undefined reference issues
- - Resource leaks and memory management problems
- - Race conditions and concurrency issues
- - Security vulnerabilities (injection, XSS, etc.)
- - Type safety violations
- - Exception handling gaps
-
-2. **Performance Analysis**: You identify:
- - Algorithmic inefficiencies (O(n²) when O(n) is possible)
- - Unnecessary database queries or API calls
- - Memory allocation patterns that cause pressure
- - Blocking operations that should be async
- - Cache opportunities
- - Resource-intensive operations in hot paths
-
-3. **Best Practices Evaluation**: You check for:
- - SOLID principles adherence
- - DRY (Don't Repeat Yourself) violations
- - Proper separation of concerns
- - Consistent naming conventions
- - Appropriate design patterns usage
- - Code organization and structure
- - Testing coverage and quality
-
-4. **Maintainability Assessment**: You evaluate:
- - Code readability and clarity
- - Documentation completeness
- - Function/class complexity (cyclomatic complexity)
- - Coupling and cohesion metrics
- - Modularity and reusability
- - Technical debt indicators
-
-Your feedback format:
-
-**Critical Issues** (Must fix):
-- List bugs and security vulnerabilities with specific line references
-- Provide clear explanations of why each is problematic
-- Suggest concrete fixes with code examples
-
-**Performance Concerns** (Should address):
-- Identify bottlenecks with performance impact estimates
-- Recommend optimizations with before/after comparisons
-- Prioritize by potential impact
-
-**Best Practice Violations** (Consider improving):
-- Note deviations from established patterns
-- Explain the benefits of following best practices
-- Provide refactored examples where helpful
-
-**Maintainability Suggestions** (Long-term health):
-- Highlight complex areas that need simplification
-- Suggest structural improvements
-- Recommend documentation additions
-
-When analyzing code:
-- Focus on the most recent changes unless instructed otherwise
-- Prioritize issues by severity and impact
-- Provide actionable feedback with specific examples
-- Balance thoroughness with practicality
-- Consider the project's context and constraints
-- Acknowledge good practices you observe
-
-You maintain a constructive tone, focusing on improvement rather than criticism. You explain not just what's wrong, but why it matters and how to fix it. You adapt your analysis depth based on the code's criticality and the project's maturity stage.
diff --git a/.agent/rules/code-quality-analyzer.md b/.agent/rules/code-quality-analyzer.md
index e7968c264..1608179e1 100644
--- a/.agent/rules/code-quality-analyzer.md
+++ b/.agent/rules/code-quality-analyzer.md
@@ -1,75 +1,30 @@
---
trigger: model_decision
-description: Use this agent when you need comprehensive analysis of code quality, including bug detection, performance optimization opportunities, and best practices compliance. This agent examines code for potential issues, evaluates maintainability, and provides actionable feedback for improvement.
+description: Use for code quality reviews covering correctness, performance, maintainability, and best practices.
---
-You are an expert software engineer specializing in code quality analysis. You have deep expertise in identifying bugs, performance bottlenecks, and violations of best practices across multiple programming languages and paradigms.
+Use this rule for focused code-quality analysis.
-## Analysis Approach
+## Apply This Rule
+- Feature completion reviews
+- Refactor validation
+- Pre-merge quality checks
-1. **Bug Detection**: You systematically examine code for:
- - Logic errors and edge cases
- - Null/undefined reference issues
- - Resource leaks and memory management problems
- - Race conditions and concurrency issues
- - Security vulnerabilities (injection, XSS, etc.)
- - Type safety violations
- - Exception handling gaps
+## Do Not Apply This Rule
+- Security-only audits (use `security-reviewer`)
+- UX-only reviews (use `ux-ui`)
+- Architecture-only strategy reviews (use `tech-lead-architect`)
-2. **Performance Analysis**: You identify:
- - Algorithmic inefficiencies (O(n²) when O(n) is possible)
- - Unnecessary database queries or API calls
- - Memory allocation patterns that cause pressure
- - Blocking operations that should be async
- - Cache opportunities
- - Resource-intensive operations in hot paths
+## Analysis Checklist
+1. Correctness and edge cases
+2. Performance hotspots and algorithmic risks
+3. Maintainability and readability issues
+4. Best-practice alignment and testing gaps
-3. **Best Practices Evaluation**: You check for:
- - SOLID principles adherence
- - DRY (Don't Repeat Yourself) violations
- - Proper separation of concerns
- - Consistent naming conventions
- - Appropriate design patterns usage
- - Code organization and structure
- - Testing coverage and quality
+## Output Format
+- Critical Issues (must fix)
+- High Priority Issues (should fix)
+- Improvement Opportunities (nice to have)
+- Testing Gaps
-4. **Maintainability Assessment**: You evaluate:
- - Code readability and clarity
- - Documentation completeness
- - Function/class complexity (cyclomatic complexity)
- - Coupling and cohesion metrics
- - Modularity and reusability
- - Technical debt indicators
-
-## Feedback Format
-
-**Critical Issues** (Must fix):
-- List bugs and security vulnerabilities with specific line references
-- Provide clear explanations of why each is problematic
-- Suggest concrete fixes with code examples
-
-**Performance Concerns** (Should address):
-- Identify bottlenecks with performance impact estimates
-- Recommend optimizations with before/after comparisons
-- Prioritize by potential impact
-
-**Best Practice Violations** (Consider improving):
-- Note deviations from established patterns
-- Explain the benefits of following best practices
-- Provide refactored examples where helpful
-
-**Maintainability Suggestions** (Long-term health):
-- Highlight complex areas that need simplification
-- Suggest structural improvements
-- Recommend documentation additions
-
-## Analysis Guidelines
-
-- Focus on the most recent changes unless instructed otherwise
-- Prioritize issues by severity and impact
-- Provide actionable feedback with specific examples
-- Balance thoroughness with practicality
-- Consider the project's context and constraints
-- Acknowledge good practices you observe
-
-You maintain a constructive tone, focusing on improvement rather than criticism. You explain not just what's wrong, but why it matters and how to fix it. You adapt your analysis depth based on the code's criticality and the project's maturity stage.
+For each issue include location, impact, and a concrete fix direction.
diff --git a/.agent/rules/code-refactoring-specialist.md b/.agent/rules/code-refactoring-specialist.md
index d33ec8702..c763bbda4 100644
--- a/.agent/rules/code-refactoring-specialist.md
+++ b/.agent/rules/code-refactoring-specialist.md
@@ -1,48 +1,32 @@
---
trigger: model_decision
-description: Use this agent when you need to refactor existing code to improve its structure, readability, and maintainability without changing its functionality. This includes simplifying complex logic, extracting methods, improving naming conventions etc
+description: Use when refactoring code for clarity, maintainability, and lower complexity without behavior changes.
---
-You are an expert code refactoring specialist with deep knowledge of clean code principles, design patterns, and software architecture. Your mission is to transform complex, hard-to-maintain code into elegant, readable, and maintainable solutions while preserving exact functionality.
-
-Your core responsibilities:
-
-1. **Analyze Code Complexity**: Identify code smells, anti-patterns, and areas of high complexity. Look for long methods, deep nesting, duplicate code, unclear naming, and violations of SOLID principles.
-
-2. **Preserve Functionality**: Ensure that all refactoring maintains the exact same behavior. Document any assumptions about current behavior and verify that tests (if present) continue to pass.
-
-3. **Apply Refactoring Techniques**:
- - Extract methods for improved readability
- - Introduce explaining variables for complex expressions
- - Replace magic numbers with named constants
- - Simplify conditional logic
- - Remove code duplication
- - Apply appropriate design patterns
- - Improve naming for clarity
- - Reduce coupling and increase cohesion
-
-4. **Maintain Context**: Consider the broader codebase context, existing patterns, and team conventions. Ensure refactored code fits naturally within the project structure.
-
-5. **Document Changes**: Provide clear explanations for each refactoring decision, including:
- - What was changed and why
- - Benefits of the new structure
- - Any trade-offs considered
- - Suggestions for further improvements
-6. **Check for current tests**: Don't forget to refactor current tests as well
-
-Your approach:
-- Start by understanding the code's purpose and current functionality
-- Identify the most impactful refactoring opportunities
-- Apply changes incrementally, ensuring functionality is preserved at each step
-- Focus on readability and maintainability over cleverness
-- Consider testability and how the refactored code will be easier to test
-- Respect existing architectural decisions unless they're clearly problematic
-
-When presenting refactored code:
-- Show the transformed code with clear improvements highlighted
-- Explain each significant change and its rationale
-- Provide metrics where applicable (e.g., reduced cyclomatic complexity)
-- Suggest next steps for continued improvement
-- Include any necessary migration notes if the refactoring affects other parts of the codebase
-
-Always prioritize code clarity and team maintainability over personal preferences. Your refactoring should make the code a joy to work with for current and future developers.
\ No newline at end of file
+Use this rule when the task is code refactoring.
+
+## Apply This Rule
+- Simplifying complex logic and deep nesting
+- Extracting methods and reducing duplication
+- Improving naming and cohesion
+
+## Do Not Apply This Rule
+- Net-new feature implementation where behavior is still evolving
+- Security-only or UX-only audits
+
+## Refactoring Guardrails
+- Preserve runtime behavior unless the task explicitly includes behavioral change.
+- Prefer small, verifiable steps over broad rewrites.
+- Keep changes aligned with existing project patterns.
+- Update or add tests relevant to refactored behavior.
+
+## Preferred Techniques
+- Early returns and guard clauses
+- Method extraction and naming improvements
+- Replace magic values with named constants
+- Reduce coupling and improve module boundaries
+
+## Output Format
+- What changed
+- Why it improves maintainability
+- Risk notes and test verification
diff --git a/.agent/rules/date-formatting.md b/.agent/rules/date-formatting.md
index 50c15373f..6cc6d47a7 100644
--- a/.agent/rules/date-formatting.md
+++ b/.agent/rules/date-formatting.md
@@ -1,45 +1,27 @@
---
trigger: model_decision
+description: Use for date/time display changes, localization, and formatting consistency in Angular templates/services.
---
-# Date Formatting Standards
+Use this rule when working with dates and localization.
-Use this rule when working with dates and localization in the Angular application.
+## Apply This Rule
+- New date/time displays in components
+- Date format refactors
+- Localization-related date behavior
-## Core Principles
+## Do Not Apply This Rule
+- Tasks without date/time formatting
-1. **Always Use Angular DatePipe**:
- * **NEVER** use JavaScript's `toLocaleDateString()` or `toLocaleTimeString()` with hardcoded locales.
- * **ALWAYS** use Angular's `DatePipe` in templates: `{{ date | date:'format' }}`
- * For programmatic formatting, inject `DatePipe` and use it with the application's locale.
+## Standards
+- Use Angular `DatePipe`; avoid direct `toLocaleDateString()` or `toLocaleTimeString()` in app code.
+- Do not hardcode locale strings in components.
+- Use project locale configuration from app setup.
+- Prefer Angular predefined formats (`shortDate`, `mediumDate`, `longDate`) unless UI requires a specific tokenized format.
-2. **Locale Configuration**:
- * The application uses dynamic `LOCALE_ID` from `navigator.language` with Greek (`el-GR`) as fallback.
- * **DO NOT** hardcode locale strings in components.
- * The locale is configured in `app.config.ts`:
- ```typescript
- { provide: LOCALE_ID, useFactory: () => navigator.language || 'el-GR' }
- ```
+## Examples
+- `{{ startDate | date:'mediumDate' }}`
+- `{{ startDate | date:'mediumDate' }} - {{ endDate | date:'mediumDate' }}`
-3. **Preferred Date Formats**:
- * Use Angular's predefined formats for consistency:
- * `'shortDate'` - e.g., "8/12/25" (locale-dependent)
- * `'mediumDate'` - e.g., "Dec 8, 2025" (locale-dependent)
- * `'longDate'` - e.g., "December 8, 2025" (locale-dependent)
- * `'EEEE, MMM d'` - e.g., "Sunday, Dec 8" for schedules
- * For timeline markers, use individual components: `'d'`, `'MMM'`, `'yyyy'`
-
-4. **Adding New Date Display**:
- * When adding date display to a component, always use `DatePipe`:
- ```html
- {{ race.startDate | date:'mediumDate' }}
- ```
- * For date ranges:
- ```html
- {{ startDate | date:'mediumDate' }} - {{ endDate | date:'mediumDate' }}
- ```
-
-## Checklist for Date-Related Changes
-- [ ] Am I using Angular's DatePipe (not native JS methods)?
-- [ ] Am I avoiding hardcoded locale strings?
-- [ ] Does the date format match existing patterns in the app?
+## Validation
+- Verify date displays in at least one non-default locale scenario when possible.
diff --git a/.agent/rules/material-design-strict.md b/.agent/rules/material-design-strict.md
index 35d69f536..ce3c42ffa 100644
--- a/.agent/rules/material-design-strict.md
+++ b/.agent/rules/material-design-strict.md
@@ -1,41 +1,28 @@
---
trigger: always_on
+description: Enforce Angular Material-first UI patterns and theme consistency.
---
# Material Design Strict Enforcement
-You are an expert in Angular Material Design 3. Your goal is to maintain a "Pure Material" aesthetic and codebase cleanliness.
+## Scope
+This always-on rule applies to frontend UI changes.
## Core Principles
-
-1. **No Custom Utility Classes**:
- * **NEVER** create new global utility classes (e.g., `.admin-card`, `.page-title`).
- * **AVOID** component-specific classes for styling (colors, borders, shadows). Use them ONLY for layout (Flexbox/Grid, margins, padding) that cannot be achieved with standard Material directives.
-
-2. **Prioritize Native Components**:
- * Always use native Angular Material components (``, ``, ``, ``) instead of custom `` structures.
- * Example: Use `
` and `` instead of ``.
-
-3. **Strict Theme Usage**:
- * **ALWAYS** use the application's defined CSS variables (`--mat-sys-*`) for all colors.
- * **NEVER** hardcode hex codes, RGB values, or standard CSS colors (e.g., `white`, `#ccc`, `red`).
- * Use `var(--mat-sys-primary)`, `var(--mat-sys-surface)`, `var(--mat-sys-on-surface)`, etc.
-
-4. **Typography**:
- * Use Material typography variables for all text styling.
- * Example: `font: var(--mat-sys-headline-medium)` instead of setting `font-size` and `font-weight` manually.
-
-5. **Refactoring**:
- * If you encounter existing custom CSS that mimics Material Design, refactor it to use the actual Material component or theme variable.
-
-## Checklist for Every UI Change
-- [ ] Am I using a standard Material component?
-- [ ] Did I avoid adding a new CSS class?
-- [ ] Are all colors using `--mat-sys-*` variables?
-- [ ] Is typography using `var(--mat-sys-*)`?
-
-6. **Dialogs & Overlays**:
- * **DO NOT** pass custom `panelClass` to `MatDialog.open()` unless there is a documented exception.
- * All dialogs automatically receive the `qs-dialog-container` class via `MAT_DIALOG_DEFAULT_OPTIONS` in `app.module.ts`.
- * Dialog styling is defined globally in `styles.scss` under the `.qs-dialog-container` rule.
- * If a dialog requires a unique style, document it in the component's README or inline comment, and use the global variables.
+1. Avoid global utility class sprawl.
+2. Prefer native Angular Material components over custom structural markup.
+3. Use theme tokens (`--mat-sys-*`) for colors and typography.
+4. Refactor custom styles that replicate Material primitives.
+
+## Allowed Custom CSS
+- Component-level classes for semantic structure, layout, and documented states.
+- Avoid hardcoded colors, custom shadows, and one-off visual systems.
+
+## Dialogs and Overlays
+- Do not add custom `panelClass` unless there is a documented exception.
+- Prefer the global dialog container conventions.
+
+## Checklist
+- Standard Material component used where available
+- No new global utility classes
+- Colors and text styles use Material tokens
diff --git a/.agent/rules/rules.md b/.agent/rules/rules.md
index a40848685..97f940f80 100644
--- a/.agent/rules/rules.md
+++ b/.agent/rules/rules.md
@@ -1,47 +1,40 @@
---
trigger: always_on
+description: Core project-wide engineering rules for quantified-self.
---
# Agent Rules for quantified-self
-## Project Overview
-- **Framework**: Angular v20+
-- **Language**: TypeScript (Loose strictness)
-- **Styling**: SCSS, Angular Material, Leaftlet for maps
-- **State Management**:
- - **MANDATORY**: Use **Angular Signals** for local component state and service-level state where possible.
- - Use RxJS (Observables, Subjects) ONLY when necessary for asynchronous streams or complex event handling.
-- **Dependency Injection**:
- - Supported: Constructor Injection (Legacy/Current).
- - Preferred for New Code: `inject()` function.
- - **Signals & Observables Naming**:
- - **STRICT RULE**: **ALWAYS** use the `$` suffix for Observables (e.g., `user$`, `isLoading$`).
- - **Signals**: Do **NOT** use the `$` suffix for Signals (e.g., `isLoading`, `user`).
- - Reason: Clear distinction between streams (Observables) and reactive state (Signals).
-
-### Firebase
-- Use **Modular SDK** (`@angular/fire` v20+, `firebase` v9+).
-- Imports should be from `@angular/fire/*` or `firebase/*`.
-- Avoid compat libraries unless strictly necessary.
-
-### Styling
-- Use **SCSS** for component styling.
-- Follow **Angular Material** theming conventions.
-- Use `app-service-source-icon` for displaying service logos (Garmin, Suunto, COROS) to ensure they are theme-aware (using `mat-icon` and `svgIcon`).
-- interactive maps use **Leaflet**.
-- **Typography**: Use **Barlow Condensed** for numeric/stat displays (values, diffs, percentages) unless a specific component style dictates otherwise.
-- **No inline component styles**: Avoid `styles: [...]` in components and inline `style=""` in templates; use external `.css/.scss` files and classes instead.
-
-### General
-- **Browser Compatibility**: Use `BrowserCompatibilityService` to check for modern API support (e.g., `CompressionStream`, `DecompressionStream`) before using them. If unsupported, the service handles showing an upgrade dialog.
-- **Strictness**: The project has `strict` mode potentially off or loose (based on `tsconfig` analysis). Ensure null checks are handled gracefully.
-- **Bailout First**: Always use "bailout first" / "return early" patterns. Avoid deep nesting of `if/else` statements. Handle validation, error checks, and edge cases at the very beginning of functions and return immediately.
-- **Directory Structure**:
- - `src/app/modules`: Feature modules.
- - `src/app/services`: Singleton services.
- - `src/app/components`: Shared components.
-
-### Code Quality & safety
-- **NO `any` Casting**:
- - **STRICT RULE**: Do **NOT** cast objects to `any` (e.g., `const data: any = { ... }`), especially when interacting with **Firestore** or external APIs.
- - **Reason**: Use strictly typed interfaces (e.g., `EventJSONInterface`) to ensure data integrity and catch type mismatches (like `Date` vs `number` timestamps) at compile time.
+## Scope
+These are baseline rules that apply across the repository unless a deeper `AGENTS.md` overrides scope.
+
+## Stack
+- Framework: Angular v20+
+- Language: TypeScript (loose strictness)
+- Styling: SCSS + Angular Material
+- Maps: Leaflet
+
+## Reactivity and Naming
+- Prefer Angular Signals for local and service state.
+- Use RxJS when stream semantics are required.
+- Observables must use `$` suffix; Signals must not.
+
+## Dependency Injection
+- Prefer `inject()` for new code.
+- Constructor injection is acceptable in existing code paths.
+
+## Firebase
+- Use modular SDK imports (`@angular/fire/*`, `firebase/*`).
+- Avoid compat APIs unless required by existing integration.
+
+## UI and Styling
+- Use external style files; avoid inline template styles and inline component style arrays.
+- Follow Material theming and CSS variable patterns.
+- Use `app-service-source-icon` for Garmin/Suunto/COROS logos.
+- Use Barlow Condensed for numeric/stat displays unless a component intentionally differs.
+
+## General Coding Rules
+- Prefer bailout-first control flow (early returns) over deep nesting.
+- Handle nullable values defensively due to loose strictness.
+- Avoid `any` casts, especially around Firestore/external payloads.
+- Use `BrowserCompatibilityService` for modern API checks before use.
diff --git a/.agent/rules/security-reviewer.md b/.agent/rules/security-reviewer.md
index 337d5e039..4fee76dd1 100644
--- a/.agent/rules/security-reviewer.md
+++ b/.agent/rules/security-reviewer.md
@@ -1,74 +1,27 @@
---
trigger: model_decision
-description: Use this agent when you need to analyze code for security vulnerabilities, potential attack vectors, or compliance with security best practices. This includes reviewing authentication mechanisms, data validation, encryption usage, SQL injection risks, XSS vulnerabilities, and other security concerns in recently written or modified code.
+description: Use for security-focused code reviews, threat analysis, and secure coding recommendations.
---
-You are an elite cybersecurity specialist with deep expertise in application security, secure coding practices, and vulnerability assessment. Your mission is to identify security flaws, potential attack vectors, and vulnerabilities in code while recommending concrete fixes and secure alternatives.
+Use this rule for security analysis.
-When reviewing code, you will:
+## Apply This Rule
+- Authentication/authorization changes
+- Input handling, parsing, upload, and API boundary changes
+- Firestore rules or sensitive data handling changes
-1. **Perform Systematic Security Analysis**:
- - Scan for common vulnerabilities (OWASP Top 10)
- - Identify injection flaws (SQL, NoSQL, LDAP, XPath, etc.)
- - Detect authentication and session management issues
- - Find sensitive data exposure risks
- - Spot XML/XXE vulnerabilities
- - Check for broken access control
- - Identify security misconfigurations
- - Detect insecure deserialization
- - Find components with known vulnerabilities
- - Check for insufficient logging and monitoring
+## Do Not Apply This Rule
+- Pure style, UX, or formatting tasks with no security impact
-2. **Analyze Attack Vectors**:
- - Map potential entry points for attackers
- - Identify trust boundaries and data flow
- - Assess the impact of successful exploits
- - Consider both external and internal threat actors
- - Evaluate defense-in-depth measures
+## Security Review Checklist
+1. Access control correctness
+2. Input validation and output encoding
+3. Secret handling and sensitive data exposure
+4. Dependency and configuration risks
+5. Abuse paths (rate limits, replay, privilege escalation)
-3. **Review Critical Security Areas**:
- - Authentication mechanisms and password policies
- - Authorization and access control logic
- - Input validation and sanitization
- - Output encoding and escaping
- - Cryptographic implementations
- - Session management
- - Error handling and information disclosure
- - Third-party dependencies and libraries
- - API security and rate limiting
- - File upload and download security
-
-4. **Provide Actionable Recommendations**:
- - Offer specific, implementable fixes for each vulnerability
- - Suggest secure coding alternatives
- - Recommend security libraries and frameworks
- - Provide code examples of secure implementations
- - Reference relevant security standards (OWASP, NIST, etc.)
- - Prioritize fixes based on severity and exploitability
-
-5. **Consider Context and Constraints**:
- - Understand the application's threat model
- - Balance security with usability and performance
- - Consider the development team's expertise level
- - Respect existing architectural decisions while suggesting improvements
- - Account for compliance requirements (PCI-DSS, HIPAA, GDPR, etc.)
-
-6. **Communicate Effectively**:
- - Explain vulnerabilities in clear, non-technical terms
- - Demonstrate potential attack scenarios
- - Quantify risk levels (Critical, High, Medium, Low)
- - Provide proof-of-concept examples where appropriate
- - Link to relevant CVEs and security advisories
-
-Your analysis should be thorough but focused on actionable findings. Avoid false positives and theoretical vulnerabilities that have no practical exploit path. Always consider the specific context of the application and its deployment environment.
-
-When you identify a vulnerability, structure your response as:
-- **Vulnerability Type**: [Category]
-- **Severity**: [Critical/High/Medium/Low]
-- **Location**: [File and line numbers]
-- **Description**: [Clear explanation of the issue]
-- **Attack Scenario**: [How it could be exploited]
-- **Recommended Fix**: [Specific code changes or practices]
-- **Secure Example**: [Code snippet showing the fix]
-
-Remember: Your goal is not just to find problems but to help developers write more secure code. Be constructive, educational, and solution-oriented in your recommendations.
\ No newline at end of file
+## Output Format
+- Severity (`Critical`, `High`, `Medium`, `Low`)
+- Location and exploit scenario
+- Recommended fix
+- Residual risk after fix
diff --git a/.agent/rules/tech-lead-architect.md b/.agent/rules/tech-lead-architect.md
index 1a9f50fff..dbb5839f3 100644
--- a/.agent/rules/tech-lead-architect.md
+++ b/.agent/rules/tech-lead-architect.md
@@ -1,39 +1,25 @@
---
trigger: model_decision
-description: Use this agent when you need senior technical leadership perspective on code architecture, system design decisions, scalability planning, technical debt assessment, or when evaluating if implementation aligns with broader project goals and engineering standards. This agent provides strategic technical guidance beyond individual code quality.
+description: Use for architecture, scalability, technical debt, and cross-cutting engineering tradeoff reviews.
---
-You are a Senior Tech Lead with 15+ years of experience leading engineering teams and making critical architectural decisions. Your expertise spans system design, scalability engineering, technical debt management, and aligning technical solutions with business objectives.
+Use this rule for senior architecture review.
-Your core responsibilities:
+## Apply This Rule
+- Significant design changes
+- Cross-module refactors
+- Scalability/reliability tradeoff decisions
-1. **Architectural Review**: Evaluate code and design decisions from a system-wide perspective. Assess whether implementations follow established architectural patterns, maintain consistency with existing systems, and support future extensibility.
+## Do Not Apply This Rule
+- Small local fixes with no architecture impact
-2. **Scalability Analysis**: Identify potential bottlenecks, performance concerns, and scaling limitations. Recommend architectural patterns and implementation strategies that will support growth from hundreds to millions of users.
+## Review Lens
+- Architectural consistency with existing system patterns
+- Operational risk and failure modes
+- Scalability bottlenecks and data flow constraints
+- Technical debt tradeoffs and incremental migration options
-3. **Technical Debt Assessment**: Recognize when shortcuts are being taken, evaluate their impact, and provide guidance on whether technical debt is acceptable given current constraints. Suggest refactoring priorities and migration strategies.
-
-4. **Standards Enforcement**: Ensure code aligns with team coding standards, best practices, and established patterns. Flag deviations and explain their potential impact on team velocity and code maintainability.
-
-5. **Strategic Alignment**: Evaluate whether technical decisions support broader project goals, product roadmap, and business objectives. Challenge over-engineering while ensuring solutions are robust enough for anticipated needs.
-
-6. **Risk Identification**: Spot architectural risks, single points of failure, and design decisions that could limit future flexibility. Provide mitigation strategies.
-
-7. **Team Considerations**: Consider how architectural decisions impact team productivity, onboarding complexity, and operational burden. Advocate for solutions that balance technical excellence with team capabilities.
-
-When reviewing code or designs:
-- Start with a high-level assessment of architectural soundness
-- Identify the most critical issues that could impact system reliability or team velocity
-- Provide specific, actionable recommendations with clear trade-offs
-- Suggest incremental improvement paths when major refactoring isn't feasible
-- Consider both immediate implementation needs and long-term maintenance costs
-- Reference specific design patterns, architectural principles, or industry best practices
-- Acknowledge when proposed solutions are good enough given constraints
-
-Your communication style is direct but constructive, focusing on educating while evaluating. You balance technical excellence with pragmatism, understanding that perfect architecture is less valuable than shipped, maintainable solutions that meet business needs.
-
-## Context7 Usage
-
-- **Architectural Patterns**: Use `context7` to research industry-standard architectural patterns and best practices for Angular and Firebase applications.
-- **Scalability Research**: Use `context7` to find case studies or documentation on scaling Firebase and Angular apps.
-- **Standards Verification**: Use `context7` to verify if a proposed solution aligns with the latest recommendations from framework authors (e.g., Angular team, Firebase team).
+## Output Format
+- Key findings ordered by impact
+- Tradeoffs and recommended direction
+- Incremental rollout plan
diff --git a/.agent/rules/technical-researcher.md b/.agent/rules/technical-researcher.md
index d1b4f27e3..c1946fbd1 100644
--- a/.agent/rules/technical-researcher.md
+++ b/.agent/rules/technical-researcher.md
@@ -1,51 +1,30 @@
---
trigger: model_decision
-description: Use this agent when you need to investigate technical solutions, compare different technologies or approaches, research best practices, evaluate frameworks or libraries, gather evidence-based recommendations for technical decisions, researching APIS
+description: Use for evidence-based technical research, option comparison, and recommendation briefs.
---
-You are a Technical Research Specialist with deep expertise in investigating, analyzing, and comparing technical solutions across various domains. Your primary role is to provide thorough, evidence-based research that helps teams make informed technical decisions.
+Use this rule when a task requires research before implementation.
-## Core Responsibilities
+## Apply This Rule
+- Comparing frameworks/libraries/tools
+- Evaluating implementation approaches
+- Producing recommendation memos with evidence
-1. **Comprehensive Investigation**: You conduct thorough research on technical topics, exploring multiple sources including official documentation, academic papers, industry reports, benchmarks, and real-world case studies. You dig deep to understand not just the surface features but the underlying principles and trade-offs.
+## Do Not Apply This Rule
+- Straightforward coding tasks with known patterns
-2. **Technology Comparison**: You excel at creating detailed comparisons between different technologies, frameworks, or approaches. You evaluate them across multiple dimensions including performance, scalability, maintainability, community support, learning curve, and long-term viability.
+## Research Method
+1. Define constraints and decision criteria.
+2. Collect primary documentation and reliable benchmarks.
+3. Compare options against the same criteria.
+4. Summarize recommendation with tradeoffs and migration risk.
-3. **Evidence-Based Analysis**: You always support your findings with concrete evidence. This includes:
- - Performance benchmarks and metrics
- - Code examples demonstrating key concepts
- - Links to authoritative sources and documentation
- - Real-world case studies and implementation examples
- - Community statistics (GitHub stars, npm downloads, Stack Overflow activity)
+## Output Format
+- Executive summary
+- Option matrix
+- Recommendation and rationale
+- Risks and mitigation
-4. **Practical Recommendations**: You provide actionable recommendations tailored to the specific context and requirements. You consider factors like team expertise, project timeline, scalability needs, and technical debt.
-
-5. **Documentation Synthesis**: You excel at synthesizing complex technical information into clear, structured reports that include:
- - Executive summaries for quick decision-making
- - Detailed technical analysis for implementation teams
- - Pros and cons matrices
- - Risk assessments and mitigation strategies
- - Migration paths and adoption roadmaps
-
-## Research Methodology
-
-- Start by clarifying the specific requirements and constraints
-- Identify key evaluation criteria relevant to the use case
-- Research multiple options, including both popular and emerging solutions
-- Gather quantitative data (benchmarks, metrics) and qualitative insights (developer experience, community feedback)
-- Consider edge cases, limitations, and potential future challenges
-- Provide balanced analysis that acknowledges trade-offs
-- Include practical examples and proof-of-concept code when relevant
-
-You maintain objectivity in your research, acknowledging biases and limitations in available data. You're transparent about uncertainty and clearly distinguish between facts, widely-accepted best practices, and opinions.
-
-When presenting findings, you structure information for different audiences - from technical deep-dives for engineers to high-level summaries for stakeholders. You always include actionable next steps and implementation guidance.
-
-Your goal is to empower teams to make confident, well-informed technical decisions backed by thorough research and clear evidence.
-
-## Context7 Usage (CRITICAL)
-
-- **Primary Research Tool**: You **MUST** use the `context7` MCP server as your primary tool for gathering technical documentation, API references, and library comparisons.
-- **Library Resolution**: Always start by resolving the library ID using `mcp_resolve-library-id` (e.g., "mapbox-gl", "firebase", "angular").
-- **Documentation Fetching**: Use `mcp_get-library-docs` to fetch authoritative documentation. Do not rely solely on internal knowledge if up-to-date docs are available via Context7.
-- **API Research**: When researching APIs, use `context7` to find the exact method signatures, parameters, and return types.
+## Context7 Usage
+- Resolve library IDs first (`mcp_resolve-library-id`).
+- Use `mcp_get-library-docs` for API-level verification.
diff --git a/.agent/rules/test-automation-engineer.md b/.agent/rules/test-automation-engineer.md
index f918db143..5b067da17 100644
--- a/.agent/rules/test-automation-engineer.md
+++ b/.agent/rules/test-automation-engineer.md
@@ -1,52 +1,25 @@
---
trigger: model_decision
-description: Use this agent when you need to create, enhance, or review automated tests for your codebase. This includes generating unit tests, integration tests, and end-to-end tests. The agent excels at understanding code functionality and translating it into comprehensive test scenarios.
+description: Use for creating or improving automated tests, coverage, and test strategy.
---
-You are an expert Test Automation Engineer specializing in creating comprehensive, maintainable, and effective automated tests. Your deep expertise spans unit testing, integration testing, end-to-end testing, and test-driven development across multiple programming languages and testing frameworks.
+Use this rule for test automation work.
-## Primary Responsibilities
+## Apply This Rule
+- Adding tests for new/changed behavior
+- Improving coverage on critical paths
+- Refactoring brittle tests
-1. **Test Generation**: Analyze provided code and automatically generate appropriate test suites that thoroughly verify functionality. Create tests that are clear, maintainable, and follow testing best practices for the specific language and framework being used.
+## Do Not Apply This Rule
+- Non-test implementation tasks unless explicitly requested
-2. **Coverage Analysis**: Identify gaps in existing test coverage by examining both the code logic and the current test suite. Suggest specific test cases for uncovered branches, edge cases, boundary conditions, and error scenarios that might have been overlooked.
-
-3. **Test Strategy**: Recommend the appropriate types of tests (unit, integration, e2e) for different components based on their complexity, dependencies, and criticality. Provide guidance on test organization, naming conventions, and fixture management.
-
-4. **Edge Case Identification**: Systematically analyze code to identify potential edge cases including:
- - Null/undefined/empty inputs
- - Boundary values (min/max integers, empty arrays, etc.)
- - Concurrent access scenarios
- - Error conditions and exception handling
- - Performance edge cases (large datasets, timeout scenarios)
- - Security-related test cases (injection attacks, authorization bypasses)
-
-5. **Test Quality**: Ensure generated tests follow the AAA (Arrange-Act-Assert) pattern, use appropriate assertions, include clear test descriptions, and avoid test interdependencies. Tests should be deterministic and fast.
-
-6. **Framework Expertise**: Adapt your test generation to the specific testing framework in use (Jest, Mocha, pytest, JUnit, RSpec, etc.) using idiomatic patterns and leveraging framework-specific features effectively.
-
-## Analysis Guidelines
-
-- First understand the code's purpose, inputs, outputs, and dependencies
-- Identify all code paths and decision points that need coverage
-- Consider both positive and negative test scenarios
-- Think about integration points and how components interact
-- Evaluate performance implications and suggest relevant performance tests
-- Consider security implications and suggest security-focused test cases
-
-## Test Generation Guidelines
-
-- Write clear, descriptive test names that explain what is being tested and expected behavior
-- Use appropriate setup and teardown to ensure test isolation
-- Mock external dependencies appropriately while avoiding over-mocking
-- Include assertions that thoroughly verify the expected behavior
-- Add comments for complex test scenarios explaining the reasoning
-- Group related tests logically using describe blocks or test classes
-
-Always strive to create tests that not only verify current functionality but also serve as living documentation and protect against future regressions. Your tests should give developers confidence to refactor and enhance code while maintaining correctness.
-
-## Context7 Usage
-
-- **Vitest**: Use `context7` to find documentation for Vitest configuration, assertions, and mocking utilities.
-- **Angular Testing Library**: Use `context7` for Angular Testing Library best practices if applicable.
+## Test Strategy
+- Prefer behavior-focused assertions over implementation details.
+- Cover happy paths, edge cases, and failure modes.
+- Keep tests deterministic and isolated.
+- Mock external dependencies at clear boundaries.
+## Output Format
+- Proposed test cases
+- Added/updated test files
+- Coverage or risk notes
diff --git a/.agent/rules/testing-expert.md b/.agent/rules/testing-expert.md
index fd712f981..9392748d4 100644
--- a/.agent/rules/testing-expert.md
+++ b/.agent/rules/testing-expert.md
@@ -1,83 +1,32 @@
---
trigger: model_decision
-description: Use this rule for Angular testing best practices, guidelines, and troubleshooting Vitest tests.
+description: Use for Angular + Vitest testing best practices and troubleshooting.
---
# Testing Guidelines
-You are an expert in testing Angular applications using **Vitest** with `@analogjs/vite-plugin-angular`. Follow these rules when writing or modifying tests.
+Use this rule when writing or debugging Angular tests with Vitest.
-## General Principles
-- **Test Behavior, Not Implementation**: Focus on what the component/service does, not how it does it.
-- **Isolation**: Unit tests should test one thing in isolation. Mock dependencies.
-- **Readability**: Tests should be easy to read and understand. Use descriptive `describe` and `it` blocks.
-- **Maintainability**: Avoid brittle tests that break with every minor code change.
-
-## Angular Testing Best Practices
-- **Use `TestBed`**: Always configure the testing module using `TestBed.configureTestingModule`.
-- **Mock Dependencies**: Use `vi.fn()` and `vi.mock()` to mock services and dependencies. Use `vi.hoisted()` for mock values needed in `vi.mock()` calls.
-- **NO_ERRORS_SCHEMA**: Use `NO_ERRORS_SCHEMA` cautiously. It hides template errors. Prefer mocking child components or using `CUSTOM_ELEMENTS_SCHEMA` if strictly necessary, but better to import necessary modules or mock components.
-- **Async Testing**: Use `fakeAsync` and `tick` for controlling time, or native async/await with Vitest.
-- **Change Detection**: Manually trigger change detection with `fixture.detectChanges()` when testing template updates.
-
-## Naming Conventions
-- **Files**: `*.spec.ts`
-- **Suites**: `describe('ComponentName', ...)`
-- **Specs**: `it('should do something', ...)`
-
-## Example Structure
-```typescript
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
-import { MyComponent } from './my.component';
-import { MyService } from '../my.service';
-
-// Hoist mocks for use in vi.mock()
-const mocks = vi.hoisted(() => ({
- getValue: vi.fn(),
-}));
-
-describe('MyComponent', () => {
- let component: MyComponent;
- let fixture: ComponentFixture;
- const mockService = { getValue: mocks.getValue };
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [MyComponent],
- providers: [
- { provide: MyService, useValue: mockService }
- ]
- }).compileComponents();
+## Apply This Rule
+- Creating or modifying `*.spec.ts`
+- Fixing flaky or failing frontend tests
- fixture = TestBed.createComponent(MyComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- vi.clearAllMocks();
- });
+## Do Not Apply This Rule
+- Backend-only test work in `functions/` (prefer backend test rules)
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- it('should call service on init', () => {
- mocks.getValue.mockReturnValue('test value');
- component.ngOnInit();
- expect(mocks.getValue).toHaveBeenCalled();
- });
-});
-```
-
-## CI/CD Integration
-- Tests are run using `npm test` (Vitest).
-- Coverage: `npm run test-coverage` generates a coverage report.
-- Firestore rules tests: `npm run test:rules` (requires Firebase emulator).
-
-## Context7 Usage
-
-- **Vitest**: Use `context7` to find documentation for Vitest configuration, assertions, and mocking utilities.
-- **Angular Testing Library**: Use `context7` for Angular Testing Library best practices if applicable.
+## General Principles
+- Test behavior, not internals.
+- Keep tests isolated and readable.
+- Prefer robust assertions over snapshot-heavy tests.
+
+## Angular + Vitest Practices
+- Configure tests with `TestBed`.
+- Use `vi.fn()` / `vi.mock()` for dependencies.
+- Use `fakeAsync` + `tick` or async/await deliberately, not mixed arbitrarily.
+- Trigger `fixture.detectChanges()` at controlled points.
+- Use `NO_ERRORS_SCHEMA` sparingly.
+
+## Naming
+- Files: `*.spec.ts`
+- Suites: `describe('ComponentName', ...)`
+- Specs: `it('should ...', ...)`
diff --git a/.agent/rules/ux-ui.md b/.agent/rules/ux-ui.md
index 087772ac5..86e5d5c3f 100644
--- a/.agent/rules/ux-ui.md
+++ b/.agent/rules/ux-ui.md
@@ -1,78 +1,32 @@
---
trigger: model_decision
-description: Use this agent when you need to evaluate frontend code, user interfaces, or web applications for user experience quality
+description: Use for UX, accessibility, and interaction-quality reviews of frontend changes.
---
-You are a User Experience (UX) Review Specialist with deep expertise in frontend development, accessibility standards, and human-computer interaction principles. Your mission is to evaluate code implementations from a user-centric perspective, ensuring they deliver exceptional experiences for all users.
+Use this rule to review frontend UX quality, accessibility, and interaction behavior.
-Your core competencies include:
-- **Accessibility Expertise**: WCAG 2.1 AA/AAA compliance, ARIA implementation, keyboard navigation, screen reader compatibility
-- **Usability Principles**: Information architecture, interaction design, cognitive load management, error prevention
-- **Design Patterns**: Material Design, Human Interface Guidelines, responsive design, progressive enhancement
-- **Performance Impact**: Perceived performance, interaction responsiveness, loading states, animation performance
-- **Cross-browser/Device**: Responsive behavior, touch interactions, viewport considerations, progressive web app features
+## Apply This Rule
+- UI component changes
+- Form and interaction flow changes
+- Accessibility and responsive behavior audits
-When reviewing code, you will:
+## Do Not Apply This Rule
+- Backend-only logic and infrastructure changes
-1. **Accessibility Audit**:
- - Verify semantic HTML usage and proper heading hierarchy
- - Check ARIA labels, roles, and states implementation
- - Ensure keyboard navigation and focus management
- - Validate color contrast ratios and text sizing
- - Identify missing alt text or inadequate descriptions
- - Test for screen reader announcement clarity
+## Review Checklist
+1. Accessibility: semantics, ARIA, keyboard flow, focus management, contrast.
+2. Usability: clarity of interactions, validation messaging, loading/empty/error states.
+3. Consistency: spacing, hierarchy, typography, design-system alignment.
+4. Performance perception: avoid jank/layout shift in common interactions.
+5. Responsive behavior: follow `.agent/rules/breakpoints.md`.
-2. **Usability Analysis**:
- - Evaluate interaction patterns for intuitiveness
- - Assess error handling and user feedback mechanisms
- - Review form validation and helper text clarity
- - Check loading states and pass/fail criteria (use `` for content blocks)
- - Verify touch target sizes (minimum 44x44px)
- - Analyze information hierarchy and visual flow
+## Review Output
+- Critical Issues
+- High Priority Issues
+- Recommendations
+- Positive Findings
-3. **Design Consistency**:
- - Verify adherence to established design systems
- - Check component reusability and consistency
- - Evaluate visual hierarchy and spacing
- - Review typography scales and readability
- - Assess color usage and theme implementation
-
-4. **Performance Considerations**:
- - Identify render-blocking resources
- - Check for unnecessary re-renders or layout shifts
- - Evaluate animation performance and smoothness
- - Review lazy loading implementation
- - Assess bundle size impact
-
-5. **Responsive Behavior**:
- - **Project Breakpoints**:
- - **Mobile**: `max-width: 768px`
- - **Container**: `max-width: 1200px`
- - Test breakpoint implementations against these standards
- - Verify mobile-first approach
- - Check viewport meta tags and scaling
- - Evaluate touch gesture support
- - Review orientation change handling
-
-Your review output will include:
-- **Critical Issues**: Accessibility violations, unusable interfaces, broken interactions
-- **High Priority**: Usability problems, inconsistent patterns, performance bottlenecks
-- **Recommendations**: Enhancement suggestions, best practice improvements
-- **Positive Findings**: Well-implemented patterns worth highlighting
-
-For each issue, provide:
-- Specific location in code
-- Clear explanation of the problem
-- User impact assessment
-- Concrete fix with code example
-- Testing methodology to verify the fix
-
-You will prioritize issues based on user impact, with accessibility and core functionality taking precedence. Your tone is constructive and educational, helping developers understand not just what to fix, but why it matters for users.
-
-Remember: Great UX is invisible when done right. Your goal is to ensure the code creates experiences that are accessible, intuitive, and delightful for all users.
+For each issue include location, impact, and concrete fix guidance.
## Context7 Usage
-
-- **Accessibility Guidelines**: Use `context7` to verify WCAG 2.1/2.2 guidelines if you are unsure about a specific accessibility requirement.
-- **Design Systems**: Use `context7` to look up Material Design 3 guidelines or other design system references.
-- **Best Practices**: Use `context7` to research UX best practices for specific patterns (e.g., "mobile navigation patterns", "form validation UX").
+- Use `context7` for WCAG and Material Design references when uncertain.
diff --git a/.agent/rules/verify-changes-with-tests.md b/.agent/rules/verify-changes-with-tests.md
index 757d2155c..4a14cda3e 100644
--- a/.agent/rules/verify-changes-with-tests.md
+++ b/.agent/rules/verify-changes-with-tests.md
@@ -1,16 +1,17 @@
---
trigger: always_on
+description: Require test verification for code changes before task completion.
---
# Test Verification Enforcement
-Whenever you modify code (refactor, feature, fix), you **MUST** verify your changes by running relevant tests.
+Whenever code is modified (feature, fix, refactor), run relevant tests.
## Requirements
-1. **Identify Tests**: Find existing tests related to the modified files.
-2. **Run Tests**: Execute the tests using `ng test` (or `npm run test`).
-3. **Verify Results**: Ensure tests pass. If they fail, you MUST fix them before considering the task complete.
-4. **No Tests?**: If no tests exist for the modified code, you should create a basic test to verify your changes, or explicitly state why testing is not possible/skipped.
+1. Identify tests related to changed files.
+2. Execute the relevant test command(s).
+3. Report pass/fail clearly.
+4. If no tests exist, add a basic test when practical or state why testing was skipped.
## Context7 Usage
-- **Test Runner Docs**: Use `context7` tools (`mcp_resolve-library-id`, `mcp_get-library-docs`) to look up command-line arguments for `ng test` or `vitest` if you need to run specific suites.
+- Use `context7` docs for runner-specific flags (`ng test`, `vitest`) when needed.
diff --git a/.firebaserc b/.firebaserc
index ed17bd206..d0816ef86 100644
--- a/.firebaserc
+++ b/.firebaserc
@@ -17,8 +17,8 @@
"etags": {
"quantified-self-io": {
"extensionInstances": {
- "delete-user-data": "0458d765e9f3db68ba5014562df202a33cc5e41ad4979542b05e81cb471ff36f",
- "firestore-send-email": "aebfef979ee9f552a9bef0b4584a1a762d981a7004b5bf1c3210092a091e344c",
+ "delete-user-data": "4fa119830db85f56f9140ed2c57543720d127f9f6434364b847db2bbe3b39ffd",
+ "firestore-send-email": "2928b61a239389834c41c239036264a161d07101c8a2070b72670609b3ead851",
"firestore-stripe-payments": "27da818f1601db655fe2e541b0a3f9303427947633748c6cdb441ca62adf5b7e"
}
}
diff --git a/AGENTS.md b/AGENTS.md
index 8e6c8e3aa..d056a1633 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,7 @@
# Agent Instructions
-Primary rules: .agent/rules/rules.md
-Role rules: .agent/rules/*.md
-Workflows: .agent/workflows/*.md
-Skills: .agent/skills/*.md
+Shared library path (keep stable for antigravity and other apps/agents): .agent/
+Primary rules: .agent/rules/verify-changes-with-tests.md
+Frontend layer: src/AGENTS.md
+Functions layer: functions/AGENTS.md
+Extensions layer: extensions/AGENTS.md
diff --git a/angular.json b/angular.json
index 225fd5a6c..a7c7d7088 100644
--- a/angular.json
+++ b/angular.json
@@ -45,7 +45,8 @@
"@amcharts/amcharts4/maps",
"@amcharts/amcharts4/themes/animated",
"@amcharts/amcharts4/themes/material",
- "@amcharts/amcharts4/themes/dark"
+ "@amcharts/amcharts4/themes/dark",
+ "xmlbuilder"
]
},
"configurations": {
@@ -59,8 +60,8 @@
},
{
"type": "allScript",
- "maximumWarning": "11809kb",
- "maximumError": "11809kb"
+ "maximumWarning": "12288kb",
+ "maximumError": "12288kb"
},
{
"type": "anyComponentStyle",
@@ -94,8 +95,8 @@
},
{
"type": "allScript",
- "maximumWarning": "11809kb",
- "maximumError": "11809kb"
+ "maximumWarning": "12288kb",
+ "maximumError": "12288kb"
},
{
"type": "anyComponentStyle",
diff --git a/extensions/AGENTS.md b/extensions/AGENTS.md
new file mode 100644
index 000000000..9a9543667
--- /dev/null
+++ b/extensions/AGENTS.md
@@ -0,0 +1,5 @@
+# Extensions Agent Instructions
+
+Shared instruction files stay in `../.agent/` for reuse by other apps/agents.
+
+Role rules: ../.agent/rules/security-reviewer.md
diff --git a/extensions/firestore-send-email.env b/extensions/firestore-send-email.env
index b1c01c929..6d644807f 100644
--- a/extensions/firestore-send-email.env
+++ b/extensions/firestore-send-email.env
@@ -14,4 +14,5 @@ SMTP_CONNECTION_URI=smtps://apikey@smtp.sendgrid.net:465
SMTP_PASSWORD=projects/${param:PROJECT_NUMBER}/secrets/firestore-send-email-SMTP_PASSWORD-u3rz/versions/latest
TEMPLATES_COLLECTION=email_templates
TTL_EXPIRE_TYPE=never
-TTL_EXPIRE_VALUE=1
\ No newline at end of file
+TTL_EXPIRE_VALUE=1
+USERS_COLLECTION=users
\ No newline at end of file
diff --git a/functions/AGENTS.md b/functions/AGENTS.md
new file mode 100644
index 000000000..4d4c00c48
--- /dev/null
+++ b/functions/AGENTS.md
@@ -0,0 +1,6 @@
+# Functions Agent Instructions
+
+Shared instruction files stay in `../.agent/` for reuse by other apps/agents.
+
+Role rules: ../.agent/rules/security-reviewer.md
+Workflows: ../.agent/workflows/start-emulators.md
diff --git a/functions/package-lock.json b/functions/package-lock.json
index cb515e0fc..1766e74e4 100644
--- a/functions/package-lock.json
+++ b/functions/package-lock.json
@@ -13,7 +13,7 @@
"@google-cloud/billing": "^5.1.1",
"@google-cloud/billing-budgets": "^6.1.1",
"@google-cloud/tasks": "^6.2.1",
- "@sports-alliance/sports-lib": "^8.0.10",
+ "@sports-alliance/sports-lib": "^9.0.17",
"blob": "^0.1.0",
"bs58": "^4.0.1",
"cors": "^2.8.5",
@@ -3642,12 +3642,12 @@
}
},
"node_modules/@sports-alliance/sports-lib": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/@sports-alliance/sports-lib/-/sports-lib-8.0.10.tgz",
- "integrity": "sha512-yN0eC4Z0z/7UIj4YGVGTgV1I8OON9pGRtZ4QUWhI2Tclf8UMbiqxXOkV9nSBxwTt1mV9Yyfbh7MHMWgswruyCA==",
+ "version": "9.0.17",
+ "resolved": "https://registry.npmjs.org/@sports-alliance/sports-lib/-/sports-lib-9.0.17.tgz",
+ "integrity": "sha512-O6XySQ2wFk1qVxZWWYx7nezQocnSPCFSGz3tvfRfydDxihmni4+f2j3jzFhem+2yuLgZvFZINnlBvc6lXyzeRg==",
"dependencies": {
"fast-xml-parser": "^5.3.3",
- "fit-file-parser": "^2.3.2",
+ "fit-file-parser": "^2.3.3",
"geolib": "^3.3.4",
"gpx-builder": "^3.7.8",
"kalmanjs": "^1.1.0",
@@ -7071,9 +7071,9 @@
}
},
"node_modules/fit-file-parser": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fit-file-parser/-/fit-file-parser-2.3.2.tgz",
- "integrity": "sha512-PWl1Qd1iHCweWCGdodbstl+g+Th7drHTKyKmR6FI/+U6huE/+dj0ZOG9vVEToOO1jLIoVCd2nA97Z0KMEudj+w==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fit-file-parser/-/fit-file-parser-2.3.3.tgz",
+ "integrity": "sha512-TZPFfjkEev5TTd9RnZ4xn4k5ZSx2VZiKNjoZsHIkmQDK0S0XA7ebfdMLj76BK7kStsHh5WbK8Fmn/w85jgd0dA==",
"dependencies": {
"buffer": "^6.0.3"
}
diff --git a/functions/package.json b/functions/package.json
index 851b69e36..22d8fc182 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -8,7 +8,7 @@
"@google-cloud/billing": "^5.1.1",
"@google-cloud/billing-budgets": "^6.1.1",
"@google-cloud/tasks": "^6.2.1",
- "@sports-alliance/sports-lib": "^8.0.10",
+ "@sports-alliance/sports-lib": "^9.0.17",
"blob": "^0.1.0",
"bs58": "^4.0.1",
"cors": "^2.8.5",
diff --git a/functions/src/garmin/queue.spec.ts b/functions/src/garmin/queue.spec.ts
index b7fd4e658..c8f01bde2 100644
--- a/functions/src/garmin/queue.spec.ts
+++ b/functions/src/garmin/queue.spec.ts
@@ -15,6 +15,7 @@ const {
mockUpdateToProcessed,
mockGetTokenData,
mockUploadDebugFile,
+ mockCreateParsingOptions,
} = vi.hoisted(() => {
return {
mockSetEvent: vi.fn(),
@@ -28,6 +29,7 @@ const {
mockUpdateToProcessed: vi.fn(),
mockGetTokenData: vi.fn(),
mockUploadDebugFile: vi.fn(),
+ mockCreateParsingOptions: vi.fn(() => ({ generateUnitStreams: false, deviceInfoMode: 'changes' })),
};
});
@@ -84,6 +86,10 @@ vi.mock('../debug-utils', () => ({
uploadDebugFile: mockUploadDebugFile,
}));
+vi.mock('../shared/parsing-options', () => ({
+ createParsingOptions: mockCreateParsingOptions,
+}));
+
// Mock queue utilities
vi.mock('../queue-utils', () => ({
increaseRetryCountForQueueItem: mockIncreaseRetryCountForQueueItem,
@@ -279,6 +285,7 @@ describe('Garmin Queue', () => { // Grouping for cleaner output
const result = await processGarminAPIActivityQueueItem(queueItem);
expect(result).toBe('PROCESSED');
+ expect(mockCreateParsingOptions).toHaveBeenCalledTimes(1);
expect(mockRequestGet).toHaveBeenCalledWith(expect.objectContaining({
headers: { 'Authorization': 'Bearer fresh-token' },
url: queueItem.callbackURL
@@ -309,6 +316,7 @@ describe('Garmin Queue', () => { // Grouping for cleaner output
expect(result).toBe('PROCESSED');
expect(EventImporterGPX.getFromString).toHaveBeenCalled();
+ expect(mockCreateParsingOptions).toHaveBeenCalledTimes(1);
});
it('should fallback to FIT if GPX parsing fails', async () => {
@@ -322,6 +330,7 @@ describe('Garmin Queue', () => { // Grouping for cleaner output
expect(EventImporterFIT.getFromArrayBuffer).toHaveBeenCalled();
// Second download attempt
expect(mockRequestGet).toHaveBeenCalledTimes(2);
+ expect(mockCreateParsingOptions).toHaveBeenCalledTimes(2);
});
it('should successfully process a TCX file', async () => {
@@ -337,6 +346,7 @@ describe('Garmin Queue', () => { // Grouping for cleaner output
expect(result).toBe('PROCESSED');
expect(EventImporterTCX.getFromXML).toHaveBeenCalled();
+ expect(mockCreateParsingOptions).toHaveBeenCalledTimes(1);
});
it('should move to DLQ if no token is found', async () => {
diff --git a/functions/src/garmin/queue.ts b/functions/src/garmin/queue.ts
index 8b1550023..a63a492b9 100644
--- a/functions/src/garmin/queue.ts
+++ b/functions/src/garmin/queue.ts
@@ -18,9 +18,9 @@ import { EventImporterTCX } from '@sports-alliance/sports-lib';
import * as xmldom from 'xmldom';
import {
GarminAPIEventMetaData,
- ActivityParsingOptions,
} from '@sports-alliance/sports-lib';
import { uploadDebugFile } from '../debug-utils';
+import { createParsingOptions } from '../shared/parsing-options';
interface RequestError extends Error {
statusCode?: number;
@@ -165,11 +165,11 @@ export async function processGarminAPIActivityQueueItem(queueItem: GarminAPIActi
let event;
switch (queueItem.activityFileType) {
case 'FIT':
- event = await EventImporterFIT.getFromArrayBuffer(result, new ActivityParsingOptions({ generateUnitStreams: false }));
+ event = await EventImporterFIT.getFromArrayBuffer(result, createParsingOptions());
break;
case 'GPX':
try {
- event = await EventImporterGPX.getFromString(result, xmldom.DOMParser, new ActivityParsingOptions({ generateUnitStreams: false }));
+ event = await EventImporterGPX.getFromString(result, xmldom.DOMParser, createParsingOptions());
} catch {
logger.error('Could not decode as GPX trying as FIT');
}
@@ -186,11 +186,14 @@ export async function processGarminAPIActivityQueueItem(queueItem: GarminAPIActi
});
logger.info('Ending timer: DownloadFileRetry');
logger.info(`Downloaded ${queueItem.activityFileType} (retry as FIT) for ${queueItem.id}`);
- event = await EventImporterFIT.getFromArrayBuffer(result, new ActivityParsingOptions({ generateUnitStreams: false }));
+ event = await EventImporterFIT.getFromArrayBuffer(result, createParsingOptions());
}
break;
case 'TCX':
- event = await EventImporterTCX.getFromXML(new xmldom.DOMParser().parseFromString(result, 'application/xml'), new ActivityParsingOptions({ generateUnitStreams: false }));
+ event = await EventImporterTCX.getFromXML(
+ new xmldom.DOMParser().parseFromString(result, 'application/xml'),
+ createParsingOptions(),
+ );
break;
}
event.name = event.startDate.toJSON(); // @todo improve
diff --git a/functions/src/queue-processing.spec.ts b/functions/src/queue-processing.spec.ts
index a9d234bb5..d91da8cf4 100644
--- a/functions/src/queue-processing.spec.ts
+++ b/functions/src/queue-processing.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { ServiceNames } from '@sports-alliance/sports-lib';
+import { ServiceNames, EventImporterFIT } from '@sports-alliance/sports-lib';
import { UsageLimitExceededError } from './utils';
// Mock dependencies using vi.hoisted
@@ -14,7 +14,8 @@ const {
mockBatch,
mockTimestamp,
mockFirestore,
- mockIncreaseRetryCountForQueueItem
+ mockIncreaseRetryCountForQueueItem,
+ mockCreateParsingOptions,
} = vi.hoisted(() => {
const mockIncreaseRetryCountForQueueItem = vi.fn(async (queueItem: any, error: any, incrementBy = 1) => {
queueItem.retryCount = (queueItem.retryCount || 0) + incrementBy;
@@ -58,6 +59,7 @@ const {
mockCollection,
mockBatch,
mockIncreaseRetryCountForQueueItem,
+ mockCreateParsingOptions: vi.fn(() => ({ generateUnitStreams: false, deviceInfoMode: 'changes' })),
};
});
@@ -117,6 +119,10 @@ vi.mock('./queue-utils', () => ({
}
}));
+vi.mock('./shared/parsing-options', () => ({
+ createParsingOptions: mockCreateParsingOptions,
+}));
+
vi.mock('firebase-functions/logger', () => ({
info: vi.fn(),
warn: vi.fn(),
@@ -187,6 +193,8 @@ describe('parseWorkoutQueueItemForServiceName', () => {
// Verify
expect(mockSetEvent).toHaveBeenCalled();
+ expect(mockCreateParsingOptions).toHaveBeenCalledTimes(1);
+ expect(EventImporterFIT.getFromArrayBuffer).toHaveBeenCalledWith(expect.anything(), expect.any(Object));
// Verify side effects of increaseRetryCountForQueueItem
// The retry count should have been incremented by 20 (0 + 20)
@@ -240,6 +248,7 @@ describe('parseWorkoutQueueItemForServiceName', () => {
undefined,
'NO_TOKEN_FOUND'
);
+ expect(mockCreateParsingOptions).not.toHaveBeenCalled();
// Verify retry count NOT increased
expect(queueItem.retryCount).toBe(0);
diff --git a/functions/src/queue.ts b/functions/src/queue.ts
index cf0bb2daf..344edbd42 100644
--- a/functions/src/queue.ts
+++ b/functions/src/queue.ts
@@ -24,8 +24,9 @@ import * as requestPromise from './request-helper';
import { config } from './config';
import { getTokenData } from './tokens';
import { EventImporterFIT } from '@sports-alliance/sports-lib';
-import { COROSAPIEventMetaData, SuuntoAppEventMetaData, ActivityParsingOptions } from '@sports-alliance/sports-lib';
+import { COROSAPIEventMetaData, SuuntoAppEventMetaData } from '@sports-alliance/sports-lib';
import { uploadDebugFile } from './debug-utils';
+import { createParsingOptions } from './shared/parsing-options';
@@ -323,7 +324,7 @@ export async function parseWorkoutQueueItemForServiceName(serviceName: ServiceNa
logger.info(`File size: ${result.byteLength || result.length} bytes for queue item ${queueItem.id}`);
try {
logger.info('Starting timer: CreateEvent');
- const event = await EventImporterFIT.getFromArrayBuffer(result, new ActivityParsingOptions({ generateUnitStreams: false }));
+ const event = await EventImporterFIT.getFromArrayBuffer(result, createParsingOptions());
logger.info('Ending timer: CreateEvent');
event.name = event.startDate.toJSON(); // @todo improve
logger.info(`Created Event from FIT file of ${queueItem.id}`);
@@ -385,7 +386,7 @@ export async function parseWorkoutQueueItemForServiceName(serviceName: ServiceNa
}
// If we finished the loop without returning, it means every token attempt failed.
- logger.error(new Error(`Could not process ANY tokens for ${queueItem.id} after checking all ${tokenQuerySnapshots.size} tokens. Increasing retry count.`));
+ logger.error(new Error(`Could not process ANY tokens for ${queueItem.id} after checking all ${tokenQuerySnapshots.size} tokens. Last error: ${lastError.message}. Increasing retry count.`));
return increaseRetryCountForQueueItem(queueItem, lastError, retryIncrement, bulkWriter);
}
diff --git a/functions/src/shared/parsing-options.spec.ts b/functions/src/shared/parsing-options.spec.ts
new file mode 100644
index 000000000..e06753075
--- /dev/null
+++ b/functions/src/shared/parsing-options.spec.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@sports-alliance/sports-lib', async (importOriginal) => {
+ const actual: any = await importOriginal();
+ return actual;
+});
+import { ActivityParsingOptions } from '@sports-alliance/sports-lib';
+import { createParsingOptions } from './parsing-options';
+
+describe('createParsingOptions', () => {
+ it('returns an ActivityParsingOptions instance', () => {
+ const options = createParsingOptions();
+ expect(options).toBeInstanceOf(ActivityParsingOptions);
+ });
+
+ it('applies function defaults for queue parsing', () => {
+ const options = createParsingOptions();
+ expect(options.generateUnitStreams).toBe(false);
+ expect(options.deviceInfoMode).toBe('changes');
+ });
+
+ it('keeps sports-lib defaults for unrelated options', () => {
+ const options = createParsingOptions();
+ expect(options.maxActivityDurationDays).toBe(14);
+ expect(options.streams).toBeDefined();
+ });
+
+ it('allows overriding generateUnitStreams', () => {
+ const options = createParsingOptions({ generateUnitStreams: true });
+ expect(options.generateUnitStreams).toBe(true);
+ expect(options.deviceInfoMode).toBe('changes');
+ });
+
+ it('allows overriding deviceInfoMode', () => {
+ const options = createParsingOptions({ deviceInfoMode: 'raw' });
+ expect(options.deviceInfoMode).toBe('raw');
+ expect(options.generateUnitStreams).toBe(false);
+ });
+
+ it('returns a new instance per invocation', () => {
+ const first = createParsingOptions();
+ const second = createParsingOptions();
+ expect(first).not.toBe(second);
+ });
+});
diff --git a/functions/src/shared/parsing-options.ts b/functions/src/shared/parsing-options.ts
new file mode 100644
index 000000000..8e0039aa5
--- /dev/null
+++ b/functions/src/shared/parsing-options.ts
@@ -0,0 +1,15 @@
+import { ActivityParsingOptions } from '@sports-alliance/sports-lib';
+
+/**
+ * Centralized parsing defaults for Firebase Functions.
+ * Keep this helper as the single source of truth for queue/import parsing options.
+ */
+export function createParsingOptions(
+ overrides: Partial = {},
+): ActivityParsingOptions {
+ return new ActivityParsingOptions({
+ generateUnitStreams: false,
+ deviceInfoMode: 'changes',
+ ...overrides,
+ });
+}
diff --git a/functions/src/suunto/activities.ts b/functions/src/suunto/activities.ts
index 16774adb4..8afe26601 100644
--- a/functions/src/suunto/activities.ts
+++ b/functions/src/suunto/activities.ts
@@ -142,6 +142,12 @@ export const importActivityToSuuntoApp = onCall({
continue;
}
+ // Check for "Already exists" before logging generic status to avoid "ERROR" noise
+ if (statusJson.status === 'ERROR' && statusJson.message === 'Already exists') {
+ logger.info(`Activity already exists in Suunto for user ${userID}.`);
+ return { status: 'info', code: 'ALREADY_EXISTS', message: 'Activity already exists in Suunto' };
+ }
+
status = statusJson.status;
logger.info(`Upload status (attempt ${attempts}/${maxAttempts}) for user ${userID}, id ${uploadId}: ${status}`, statusJson);
@@ -149,10 +155,7 @@ export const importActivityToSuuntoApp = onCall({
logger.info(`Successfully processed activity for user ${userID}. WorkoutKey: ${statusJson.workoutKey}`);
return { status: 'success', message: 'Activity uploaded to Suunto', workoutKey: statusJson.workoutKey };
} else if (status === 'ERROR') {
- if (statusJson.message === 'Already exists') {
- logger.info(`Activity already exists in Suunto for user ${userID}.`);
- return { status: 'info', code: 'ALREADY_EXISTS', message: 'Activity already exists in Suunto' };
- }
+ // The "Already exists" case is handled above
throw new HttpsError('internal', `Suunto processing failed: ${statusJson.message}`);
} else if (status === 'NEW' || status === 'ACCEPTED') {
// Continue polling
diff --git a/package-lock.json b/package-lock.json
index 9f3ef114e..1a758e893 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "quantified-self",
- "version": "7.2.0",
+ "version": "7.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quantified-self",
- "version": "7.2.0",
+ "version": "7.3.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@amcharts/amcharts4": "^4.10.40",
@@ -26,21 +26,21 @@
"@googlemaps/js-api-loader": "^2.0.2",
"@googlemaps/markerclusterer": "^2.6.2",
"@sentry/angular": "^10.34.0",
- "@sports-alliance/sports-lib": "^8.0.10",
+ "@sports-alliance/sports-lib": "^9.0.17",
"@types/file-saver": "^2.0.7",
"@types/google.maps": "^3.58.1",
"@zumer/snapdom": "^2.0.2",
"buffer": "^6.0.3",
- "chart.js": "^4.5.1",
"dayjs": "^1.11.19",
+ "echarts": "^6.0.0",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"firebase": "^12.8.0",
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
+ "lodash-es": "^4.17.23",
"mapbox-gl": "^3.10.0",
"marked": "^15.0.12",
- "ng2-charts": "^8.0.0",
"rxjs": "^7.8.2",
"weeknumber": "^1.2.1",
"zone.js": "~0.15.1"
@@ -5501,11 +5501,6 @@
"tslib": "2"
}
},
- "node_modules/@kurkle/color": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
- "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
- },
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -7412,12 +7407,12 @@
}
},
"node_modules/@sports-alliance/sports-lib": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/@sports-alliance/sports-lib/-/sports-lib-8.0.10.tgz",
- "integrity": "sha512-yN0eC4Z0z/7UIj4YGVGTgV1I8OON9pGRtZ4QUWhI2Tclf8UMbiqxXOkV9nSBxwTt1mV9Yyfbh7MHMWgswruyCA==",
+ "version": "9.0.17",
+ "resolved": "https://registry.npmjs.org/@sports-alliance/sports-lib/-/sports-lib-9.0.17.tgz",
+ "integrity": "sha512-O6XySQ2wFk1qVxZWWYx7nezQocnSPCFSGz3tvfRfydDxihmni4+f2j3jzFhem+2yuLgZvFZINnlBvc6lXyzeRg==",
"dependencies": {
"fast-xml-parser": "^5.3.3",
- "fit-file-parser": "^2.3.2",
+ "fit-file-parser": "^2.3.3",
"geolib": "^3.3.4",
"gpx-builder": "^3.7.8",
"kalmanjs": "^1.1.0",
@@ -9333,17 +9328,6 @@
"integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==",
"dev": true
},
- "node_modules/chart.js": {
- "version": "4.5.1",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
- "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
- "dependencies": {
- "@kurkle/color": "^0.3.0"
- },
- "engines": {
- "pnpm": ">=8"
- }
- },
"node_modules/cheap-ruler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
@@ -10394,6 +10378,20 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/echarts": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
+ "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.0.0"
+ }
+ },
+ "node_modules/echarts/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -11338,9 +11336,9 @@
}
},
"node_modules/fit-file-parser": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fit-file-parser/-/fit-file-parser-2.3.2.tgz",
- "integrity": "sha512-PWl1Qd1iHCweWCGdodbstl+g+Th7drHTKyKmR6FI/+U6huE/+dj0ZOG9vVEToOO1jLIoVCd2nA97Z0KMEudj+w==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fit-file-parser/-/fit-file-parser-2.3.3.tgz",
+ "integrity": "sha512-TZPFfjkEev5TTd9RnZ4xn4k5ZSx2VZiKNjoZsHIkmQDK0S0XA7ebfdMLj76BK7kStsHh5WbK8Fmn/w85jgd0dA==",
"dependencies": {
"buffer": "^6.0.3"
}
@@ -13576,9 +13574,9 @@
}
},
"node_modules/lodash-es": {
- "version": "4.17.22",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
- "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
@@ -14467,23 +14465,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
- "node_modules/ng2-charts": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-8.0.0.tgz",
- "integrity": "sha512-nofsNHI2Zt+EAwT+BJBVg0kgOhNo9ukO4CxULlaIi7VwZSr7I1km38kWSoU41Oq6os6qqIh5srnL+CcV+RFPFA==",
- "dependencies": {
- "lodash-es": "^4.17.15",
- "tslib": "^2.3.0"
- },
- "peerDependencies": {
- "@angular/cdk": ">=19.0.0",
- "@angular/common": ">=19.0.0",
- "@angular/core": ">=19.0.0",
- "@angular/platform-browser": ">=19.0.0",
- "chart.js": "^3.4.0 || ^4.0.0",
- "rxjs": "^6.5.3 || ^7.4.0"
- }
- },
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -19666,6 +19647,19 @@
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w=="
+ },
+ "node_modules/zrender": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
+ "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ },
+ "node_modules/zrender/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
}
diff --git a/package.json b/package.json
index 07af2f925..ff1c6aede 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "quantified-self",
- "version": "7.2.1",
+ "version": "7.3.0",
"license": "SEE LICENSE IN LICENSE.md",
"scripts": {
"ng": "ng",
@@ -43,21 +43,21 @@
"@googlemaps/js-api-loader": "^2.0.2",
"@googlemaps/markerclusterer": "^2.6.2",
"@sentry/angular": "^10.34.0",
- "@sports-alliance/sports-lib": "^8.0.10",
+ "@sports-alliance/sports-lib": "^9.0.17",
"@types/file-saver": "^2.0.7",
"@types/google.maps": "^3.58.1",
"@zumer/snapdom": "^2.0.2",
"buffer": "^6.0.3",
- "chart.js": "^4.5.1",
"dayjs": "^1.11.19",
+ "echarts": "^6.0.0",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"firebase": "^12.8.0",
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
+ "lodash-es": "^4.17.23",
"mapbox-gl": "^3.10.0",
"marked": "^15.0.12",
- "ng2-charts": "^8.0.0",
"rxjs": "^7.8.2",
"weeknumber": "^1.2.1",
"zone.js": "~0.15.1"
diff --git a/src/AGENTS.md b/src/AGENTS.md
new file mode 100644
index 000000000..b6d8d4493
--- /dev/null
+++ b/src/AGENTS.md
@@ -0,0 +1,6 @@
+# Frontend Agent Instructions
+
+Shared instruction files stay in `../.agent/` for reuse by other apps/agents.
+
+Primary rules: ../.agent/rules/rules.md
+Role rules: ../.agent/rules/material-design-strict.md
diff --git a/src/app/animations/animations.ts b/src/app/animations/animations.ts
index 3754f9b38..2eaae73ca 100644
--- a/src/app/animations/animations.ts
+++ b/src/app/animations/animations.ts
@@ -27,18 +27,16 @@ export const expandCollapse =
export const slideInAnimation =
trigger('routeAnimations', [
transition('* <=> *', [
- query(':enter, :leave', style({ position: 'fixed', width: '100%' }), { optional: true }),
+ query(':enter, :leave', style({ position: 'absolute', inset: 0, width: '100%' }), { optional: true }),
group([
query(':enter', [
- style({ opacity: 0, transform: 'translateX(100%)' }),
- animate('0.65s ease', style({ opacity: 1, transform: 'translateX(0%)' }))
+ style({ opacity: 0 }),
+ animate('433ms ease-out', style({ opacity: 1 }))
], { optional: true }),
query(':leave', [
- style({ opacity: 1, transform: 'translateX(0%)' }),
- animate('0.65s ease', style({ opacity: 0, transform: 'translateX(-100%)' }))
+ style({ opacity: 1 }),
+ animate('180ms ease-in', style({ opacity: 0 }))
], { optional: true }),
])
]),
]);
-
-
diff --git a/src/app/app.component.html b/src/app/app.component.html
index e3f1ed844..8fb79ff41 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -5,7 +5,10 @@
}
-
+
@@ -78,10 +81,9 @@
}
-
+
@if (showNavigation) {
-
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index 5b58b7e11..fa9343631 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -1,7 +1,13 @@
/* Main App Layout */
/* Note: margin-top is set dynamically via [style.margin-top.px] binding */
mat-sidenav-container {
- min-height: calc(100vh - 64px);
+ height: calc(100vh - var(--qs-layout-top-offset, 0px));
+}
+
+@supports (height: 100dvh) {
+ mat-sidenav-container {
+ height: calc(100dvh - var(--qs-layout-top-offset, 0px));
+ }
}
mat-sidenav {
@@ -87,10 +93,6 @@ mat-icon.header-logo {
-.no-margin-top {
- margin-top: 0 !important;
-}
-
/* Utilities */
.fill-space {
flex: 1 1 auto;
@@ -269,4 +271,4 @@ mat-progress-bar {
100% {
background-position: -100% 0;
}
-}
\ No newline at end of file
+}
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
index a7d4b7427..feef9d3c5 100644
--- a/src/app/app.component.spec.ts
+++ b/src/app/app.component.spec.ts
@@ -14,7 +14,7 @@ import { AppWhatsNewService } from './services/app.whats-new.service';
import { MatDialog } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { Router, RouterModule, ActivatedRoute } from '@angular/router';
+import { Router, RouterModule, ActivatedRoute, NavigationEnd } from '@angular/router';
import { DomSanitizer } from '@angular/platform-browser';
import { of, Subject } from 'rxjs';
import { MatTabsModule } from '@angular/material/tabs';
@@ -180,6 +180,30 @@ describe('AppComponent', () => {
expect(bannerComponent).toBeTruthy();
});
+ it('should expose layout css variables based on banner height', () => {
+ component.onboardingCompleted = true;
+ component.onBannerHeightChanged(36);
+ fixture.detectChanges();
+
+ const wrapper = fixture.nativeElement.querySelector('.app-layout-wrapper') as HTMLElement | null;
+ expect(wrapper).toBeTruthy();
+ expect(wrapper?.style.getPropertyValue('--qs-layout-top-offset')).toBe('100px');
+ expect(wrapper?.style.getPropertyValue('--qs-effective-top-offset')).toBe('100px');
+ expect(wrapper?.style.getPropertyValue('--qs-banner-height')).toBe('36px');
+ });
+
+ it('should expose zero effective top offset when onboarding is not completed', () => {
+ component.onboardingCompleted = false;
+ component.onBannerHeightChanged(36);
+ fixture.detectChanges();
+
+ const wrapper = fixture.nativeElement.querySelector('.app-layout-wrapper') as HTMLElement | null;
+ expect(wrapper).toBeTruthy();
+ expect(wrapper?.style.getPropertyValue('--qs-layout-top-offset')).toBe('0px');
+ expect(wrapper?.style.getPropertyValue('--qs-effective-top-offset')).toBe('0px');
+ expect(wrapper?.style.getPropertyValue('--qs-banner-height')).toBe('0px');
+ });
+
it('should return true for isDashboardRoute when url includes dashboard', () => {
mockRouter.url = '/dashboard';
expect(component.isDashboardRoute).toBe(true);
@@ -209,4 +233,37 @@ describe('AppComponent', () => {
mockRouter.url = '/dashboard';
expect(component.isHomeRoute).toBe(false);
});
+
+ it('prepareRoute should return Event animation after first load', () => {
+ (component as any).isFirstLoad = false;
+ const outlet = { activatedRouteData: { animation: 'Event' } } as any;
+
+ expect(component.prepareRoute(outlet)).toBe('Event');
+ });
+
+ it('prepareRoute should return non-Event animation after first load', () => {
+ (component as any).isFirstLoad = false;
+ const outlet = { activatedRouteData: { animation: 'Dashboard' } } as any;
+
+ expect(component.prepareRoute(outlet)).toBe('Dashboard');
+ });
+
+ it('should reset scroll to top on NavigationEnd', () => {
+ const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => { });
+ fixture.detectChanges();
+
+ const shellScroller = fixture.nativeElement.querySelector('.mat-drawer-content') as HTMLElement | null;
+ if (shellScroller) {
+ shellScroller.scrollTop = 120;
+ shellScroller.scrollLeft = 20;
+ }
+
+ (mockRouter.events as Subject).next(new NavigationEnd(1, '/dashboard', '/dashboard'));
+
+ if (shellScroller) {
+ expect(shellScroller.scrollTop).toBe(0);
+ expect(shellScroller.scrollLeft).toBe(0);
+ }
+ expect(scrollSpy).toHaveBeenCalled();
+ });
});
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 7e4f3b601..d5b7f5dfc 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -76,6 +76,10 @@ export class AppComponent implements OnInit, OnDestroy {
private breakpointObserver = inject(BreakpointObserver);
public isHandset = toSignal(this.breakpointObserver.observe([Breakpoints.XSmall, Breakpoints.Small]).pipe(map(result => result.matches)), { initialValue: false });
+ get layoutTopOffsetPx(): number {
+ return this.showNavigation ? this.bannerHeight + 64 : 0;
+ }
+
constructor(
public authService: AppAuthService,
private userService: AppUserService,
@@ -128,6 +132,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.routerEventSubscription = this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.updateOnboardingState();
+ this.scrollToTopAfterNavigation();
}
});
@@ -160,6 +165,8 @@ export class AppComponent implements OnInit, OnDestroy {
}
private updateOnboardingState() {
+ const previousOnboardingRoute = this.isOnboardingRoute;
+ const previousOnboardingCompleted = this.onboardingCompleted;
const user = this.currentUser;
const url = this.router.url;
this.isOnboardingRoute = url.includes('onboarding');
@@ -186,7 +193,13 @@ export class AppComponent implements OnInit, OnDestroy {
// Not logged in - show chrome (login/landing page)
this.onboardingCompleted = true;
}
- this.changeDetectorRef.detectChanges();
+ const hasStateChanged =
+ previousOnboardingRoute !== this.isOnboardingRoute ||
+ previousOnboardingCompleted !== this.onboardingCompleted;
+
+ if (hasStateChanged) {
+ this.changeDetectorRef.detectChanges();
+ }
}
get showNavigation(): boolean {
@@ -223,8 +236,12 @@ export class AppComponent implements OnInit, OnDestroy {
}
onBannerHeightChanged(height: number) {
+ const nextHasBanner = height > 0;
+ if (this.bannerHeight === height && this.hasBanner === nextHasBanner) {
+ return;
+ }
this.bannerHeight = height;
- this.hasBanner = height > 0;
+ this.hasBanner = nextHasBanner;
this.changeDetectorRef.detectChanges();
}
@@ -235,6 +252,26 @@ export class AppComponent implements OnInit, OnDestroy {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];
}
+ private scrollToTopAfterNavigation(): void {
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
+ return;
+ }
+
+ // Reset the shell scroller used by mat-sidenav layouts.
+ const shellScroller = document.querySelector('.app-sidenav-container .mat-drawer-content') as HTMLElement | null;
+ if (shellScroller) {
+ shellScroller.scrollTop = 0;
+ shellScroller.scrollLeft = 0;
+ }
+
+ // Keep default window restoration behavior aligned as a fallback.
+ try {
+ window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
+ } catch {
+ window.scrollTo(0, 0);
+ }
+ }
+
private triggerCircularReveal(x: number, y: number, theme: any) {
// Set the overlay class based on new theme
this.themeOverlayClass = theme === 'Dark' ? 'dark-theme' : '';
diff --git a/src/app/app.module.menu-default-options.spec.ts b/src/app/app.module.menu-default-options.spec.ts
new file mode 100644
index 000000000..af8488e0a
--- /dev/null
+++ b/src/app/app.module.menu-default-options.spec.ts
@@ -0,0 +1,24 @@
+import { MAT_MENU_DEFAULT_OPTIONS } from '@angular/material/menu';
+import { describe, expect, it } from 'vitest';
+import { AppModule, QS_MENU_DEFAULT_OPTIONS } from './app.module';
+
+describe('AppModule menu defaults', () => {
+ it('exports the expected default menu options', () => {
+ expect(QS_MENU_DEFAULT_OPTIONS).toEqual({
+ overlayPanelClass: 'qs-menu-panel',
+ hasBackdrop: true,
+ overlapTrigger: false,
+ xPosition: 'after',
+ yPosition: 'below',
+ backdropClass: 'cdk-overlay-transparent-backdrop'
+ });
+ });
+
+ it('registers MAT_MENU_DEFAULT_OPTIONS in AppModule providers', () => {
+ const providers = ((AppModule as any).ɵinj?.providers ?? []) as Array;
+ const menuProvider = providers.find((provider) => provider?.provide === MAT_MENU_DEFAULT_OPTIONS);
+
+ expect(menuProvider).toBeTruthy();
+ expect(menuProvider.useValue).toEqual(QS_MENU_DEFAULT_OPTIONS);
+ });
+});
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 7eeedfe9d..1b92447a7 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -8,11 +8,11 @@ import { SideNavComponent } from './components/sidenav/sidenav.component';
import { environment } from '../environments/environment';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
-import { provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth';
+import { provideAuth, getAuth } from '@angular/fire/auth';
import { provideFirestore, initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from '@angular/fire/firestore';
import { getApp } from '@angular/fire/app';
import { provideFunctions, getFunctions } from '@angular/fire/functions';
-import { provideAppCheck, initializeAppCheck, ReCaptchaV3Provider, AppCheck } from '@angular/fire/app-check';
+import { provideAppCheck, initializeAppCheck, ReCaptchaV3Provider } from '@angular/fire/app-check';
import { providePerformance, getPerformance } from '@angular/fire/performance';
import { provideAnalytics, getAnalytics, ScreenTrackingService, UserTrackingService, setAnalyticsCollectionEnabled, initializeAnalytics } from '@angular/fire/analytics';
import { provideRemoteConfig, getRemoteConfig } from '@angular/fire/remote-config';
@@ -24,10 +24,12 @@ import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
import { MAT_BOTTOM_SHEET_DEFAULT_OPTIONS } from '@angular/material/bottom-sheet';
import { MAT_ICON_DEFAULT_OPTIONS } from '@angular/material/icon';
+import { MAT_MENU_DEFAULT_OPTIONS, MatMenuDefaultOptions } from '@angular/material/menu';
import { ServiceWorkerModule } from '@angular/service-worker';
import { UploadActivitiesComponent } from './components/upload/upload-activities/upload-activities.component';
import { GoogleMapsLoaderService } from './services/google-maps-loader.service';
import { LoggerService } from './services/logger.service';
+import { maybeConnectAuthEmulator } from './authentication/auth-emulator.config';
import { AppUpdateService } from './services/app.update.service';
import { OnboardingComponent } from './components/onboarding/onboarding.component';
@@ -41,6 +43,16 @@ import { firstValueFrom } from 'rxjs';
import { MAT_DATE_LOCALE_PROVIDER, getBrowserLocale } from './shared/adapters/date-locale.config';
import { APP_STORAGE } from './services/storage/app.storage.token';
+export const QS_MENU_DEFAULT_OPTIONS: MatMenuDefaultOptions = {
+ overlayPanelClass: 'qs-menu-panel',
+ hasBackdrop: true,
+ overlapTrigger: false,
+ xPosition: 'after',
+ yPosition: 'below',
+ backdropClass: 'cdk-overlay-transparent-backdrop'
+};
+
+const enableAppCheck = environment.production || environment.beta || environment.localhost;
@NgModule({
declarations: [
@@ -72,21 +84,14 @@ import { APP_STORAGE } from './services/storage/app.storage.token';
},
provideHttpClient(withInterceptorsFromDi()),
provideFirebaseApp(() => initializeApp(environment.firebase)),
- provideAppCheck(() => {
+ ...(enableAppCheck ? [provideAppCheck(() => {
const provider = new ReCaptchaV3Provider(environment.firebase.recaptchaSiteKey);
- if (!environment.production && !environment.beta) {
- (window as any).FIREBASE_APPCHECK_DEBUG_TOKEN = true;
- } else {
- (window as any).FIREBASE_APPCHECK_DEBUG_TOKEN = false;
- }
+ (window as any).FIREBASE_APPCHECK_DEBUG_TOKEN = !environment.production && !environment.beta;
return initializeAppCheck(getApp(), { provider, isTokenAutoRefreshEnabled: true });
- }),
+ })] : []),
provideAuth(() => {
const auth = getAuth();
- if (environment.useAuthEmulator) {
- connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
- }
- return auth;
+ return maybeConnectAuthEmulator(auth);
}),
// Use initializeFirestore with ignoreUndefinedProperties to handle undefined values
// in activity/event data (e.g., TCX files may have undefined creator.manufacturer).
@@ -98,7 +103,7 @@ import { APP_STORAGE } from './services/storage/app.storage.token';
useFetchStreams: true,
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
- cacheSizeBytes: 104857600 // 100 MB
+ cacheSizeBytes: 1073741824 // 1 GB
}),
});
@@ -122,6 +127,7 @@ import { APP_STORAGE } from './services/storage/app.storage.token';
provideRemoteConfig(() => getRemoteConfig()),
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
{ provide: MAT_ICON_DEFAULT_OPTIONS, useValue: { fontSet: 'material-symbols-rounded' } },
+ { provide: MAT_MENU_DEFAULT_OPTIONS, useValue: QS_MENU_DEFAULT_OPTIONS },
{ provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: { panelClass: 'qs-dialog-container', hasBackdrop: true } },
{ provide: MAT_BOTTOM_SHEET_DEFAULT_OPTIONS, useValue: { autoFocus: 'dialog', panelClass: 'qs-bottom-sheet-container' } },
MAT_DATE_LOCALE_PROVIDER,
diff --git a/src/app/authentication/auth-emulator.config.spec.ts b/src/app/authentication/auth-emulator.config.spec.ts
new file mode 100644
index 000000000..a9a2484de
--- /dev/null
+++ b/src/app/authentication/auth-emulator.config.spec.ts
@@ -0,0 +1,54 @@
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Auth } from '@angular/fire/auth';
+import { environment } from '../../environments/environment';
+
+const { connectAuthEmulatorMock } = vi.hoisted(() => ({
+ connectAuthEmulatorMock: vi.fn(),
+}));
+
+vi.mock('@angular/fire/auth', async () => {
+ const actual = await vi.importActual('@angular/fire/auth');
+ return {
+ ...actual,
+ connectAuthEmulator: connectAuthEmulatorMock,
+ };
+});
+
+import { maybeConnectAuthEmulator } from './auth-emulator.config';
+
+describe('maybeConnectAuthEmulator', () => {
+ const originalUseAuthEmulator = environment.useAuthEmulator;
+
+ beforeEach(() => {
+ connectAuthEmulatorMock.mockReset();
+ environment.useAuthEmulator = originalUseAuthEmulator;
+ });
+
+ afterAll(() => {
+ environment.useAuthEmulator = originalUseAuthEmulator;
+ });
+
+ it('should connect to auth emulator when enabled', () => {
+ environment.useAuthEmulator = true;
+ const mockAuth = {} as Auth;
+
+ const result = maybeConnectAuthEmulator(mockAuth);
+
+ expect(result).toBe(mockAuth);
+ expect(connectAuthEmulatorMock).toHaveBeenCalledWith(
+ mockAuth,
+ 'http://localhost:9099',
+ { disableWarnings: true }
+ );
+ });
+
+ it('should not connect to auth emulator when disabled', () => {
+ environment.useAuthEmulator = false;
+ const mockAuth = {} as Auth;
+
+ const result = maybeConnectAuthEmulator(mockAuth);
+
+ expect(result).toBe(mockAuth);
+ expect(connectAuthEmulatorMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/authentication/auth-emulator.config.ts b/src/app/authentication/auth-emulator.config.ts
new file mode 100644
index 000000000..f79fe3429
--- /dev/null
+++ b/src/app/authentication/auth-emulator.config.ts
@@ -0,0 +1,11 @@
+import { Auth, connectAuthEmulator } from '@angular/fire/auth';
+import { environment } from '../../environments/environment';
+
+export function maybeConnectAuthEmulator(auth: Auth): Auth {
+ if (!environment.useAuthEmulator) {
+ return auth;
+ }
+
+ connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
+ return auth;
+}
diff --git a/src/app/components/activity-actions/activity.actions.component.css b/src/app/components/activity-actions/activity.actions.component.css
index a22f7f107..b28200db2 100644
--- a/src/app/components/activity-actions/activity.actions.component.css
+++ b/src/app/components/activity-actions/activity.actions.component.css
@@ -5,12 +5,22 @@
}
mat-icon.toolTip{
- margin-left: 0.5em;
font-size: 18px;
width: 18px;
height: 18px;
}
+.regenerate-stat-item {
+ margin-top: 6px;
+}
+
+.menu-item-with-tooltip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ line-height: 1.2;
+}
+
.mat-icon-button{
vertical-align: middle;
}
diff --git a/src/app/components/activity-actions/activity.actions.component.html b/src/app/components/activity-actions/activity.actions.component.html
index 4fe7c9079..5a5f8f4dc 100644
--- a/src/app/components/activity-actions/activity.actions.component.html
+++ b/src/app/components/activity-actions/activity.actions.component.html
@@ -1,7 +1,7 @@
more_vert
-
+
\ 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) {
}
-
\ 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 @@
-
-
-
+
+
+
+ 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 @@
-
-
-
+
@@ -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';
share
-
+