diff --git a/.cursor/rules/commits.mdc b/.cursor/rules/commits.mdc index c17af27..5d99190 100644 --- a/.cursor/rules/commits.mdc +++ b/.cursor/rules/commits.mdc @@ -45,25 +45,51 @@ macro: update components Before making any commit, you MUST complete ALL of the following steps: -> ⚠️ **CRITICAL REMINDER**: Steps 1 and 6 are often forgotten but are MANDATORY! - -### 1. ✅ Remove Unnecessary Comments ⚠️ CRITICAL STEP +> ⚠️ **CRITICAL REMINDER**: Steps 1, 2, and 7 are often forgotten but are MANDATORY! + +### 1. ✅ Check Git Branch and Create Feature Branch ⚠️ CRITICAL STEP +- **MANDATORY**: Check current branch with `git branch` or `git status` +- **If on main branch**: Create new feature branch before committing +- **Commands to execute**: + ```bash + # Check current branch + git branch + + # If on main, create feature branch + git checkout -b feature/[descriptive-name] + + # Examples: + # git checkout -b feature/pwa-update-fixes + # git checkout -b feature/feature-workout-diary + # git checkout -b feature/auth-service-improvements + ``` +- **Branch naming convention**: `feature/[kebab-case-description]` +- **If working from a plan**: Use plan name as branch name + - **Example**: If plan is `feature-workout-diary.md`, branch should be `feature/feature-workout-diary` + - **Example**: If plan is `auth-service-improvements.md`, branch should be `feature/auth-service-improvements` + - **Plan file location**: `docs/plans/[plan-name].md` + - **Update plan status** as you progress through implementation +- **NEVER commit directly to main branch** +- **After creating feature branch**: Continue with steps 2-8 +- **This step is CRITICAL for proper Git workflow!** + +### 2. ✅ Remove Unnecessary Comments ⚠️ CRITICAL STEP - **MANDATORY**: Delete ALL unnecessary comments from code before commit - Use `grep -r "^\s*//" src/` to find all comments in changed files - Keep only essential documentation comments - **This step is often forgotten but is required!** -### 2. ✅ Fix Linter Errors +### 3. ✅ Fix Linter Errors - Run `npm run lint` and fix all errors - Ensure all files pass linting rules - Fix TypeScript, Angular, and style linting issues -### 3. ✅ Verify Application Build +### 4. ✅ Verify Application Build - Run `npm run build` and ensure successful compilation - Fix any build errors or warnings - Verify all imports and dependencies are correct -### 4. ✅ Check Test Coverage +### 5. ✅ Check Test Coverage - Run `npm run test:ci` to check test coverage - Ensure minimum coverage requirements are met: - 80% statements @@ -72,21 +98,67 @@ Before making any commit, you MUST complete ALL of the following steps: - 80% lines - Add tests for new functionality if coverage is insufficient -### 5. ✅ Verify All Tests Pass +### 6. ✅ Verify All Tests Pass - All tests must pass in headless mode - No tests should be skipped or failing - Fix any broken tests before committing -### 6. ✅ Update Project Structure Documentation ⚠️ CRITICAL STEP +### 7. ✅ Update Project Structure Documentation ⚠️ CRITICAL STEP - **MANDATORY**: Update `.cursor/rules/project-structure.mdc` if project structure changed - Add new components, services, or features to documentation - Keep architecture documentation current - **This step is often forgotten but is required!** +### 8. ✅ Update PWA Version ⚠️ CRITICAL STEP +- **MANDATORY**: Update version in `ngsw-config.json` ONLY when application code changes +- **DO NOT update version** for test-only changes, documentation updates, or configuration changes +- Increment the version number in `appData.version` field only for functional changes +- This ensures PWA updates are detected by users only when needed +- **Example**: Change `"version": "1.0.4"` to `"version": "1.0.5"` only for app code changes +- **This step is critical for PWA update functionality!** + ## Important Notes - **Never commit with failing tests** - even if tests are unrelated to your changes - **Always run tests in headless mode** - use `npm run test:ci` - **Commit messages must be in English** and concise (max 3 lines) - **Include branch name** at the beginning of commit message -- **Do not include test results** in commit message description \ No newline at end of file +- **Do not include test results** in commit message description +- **Update PWA version ONLY for application code changes** in `ngsw-config.json` to ensure updates work when needed + +## After Committing to Feature Branch + +### Push Feature Branch +```bash +# Push feature branch to remote +git push -u origin feature/[branch-name] + +# Example: git push -u origin feature/pwa-update-fixes +``` + +### Create Pull Request +- Create Pull Request from feature branch to main +- Add descriptive title and description +- Request code review if needed +- Merge after approval + +### Clean Up After Merge +```bash +# Switch back to main +git checkout main + +# Pull latest changes +git pull origin main + +# Delete local feature branch +git branch -d feature/[branch-name] + +# Delete remote feature branch (if needed) +git push origin --delete feature/[branch-name] +``` + +### Plan Management +- **If working from a plan**: Update plan status as you progress +- **Mark completed tasks** in the plan file +- **After feature completion**: Consider archiving or updating the plan +- **Plan location**: `docs/plans/[plan-name].md` \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 3e979c6..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- - -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 -- Implement lazy loading for feature routes -- 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. -## 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 -- separate style and template files for components -## 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 -## Services -- Design services around a single responsibility -- Use the `providedIn: 'root'` option for singleton services -- Use the `inject()` function instead of constructor injection -## Common pitfalls -- Control flow (`@if`): -- You cannot use `as` expressions in `@else if (...)`. E.g. invalid code: `@else if (bla(); as x)`. - -- use fsd architecture -- Use feature slices for each feature in the application -- all text in app on english diff --git a/ngsw-config.json b/ngsw-config.json index 070a6e4..542876f 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -49,6 +49,6 @@ } ], "appData": { - "version": "1.0.4" + "version": "1.0.6" } } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index dddd9b0..e19d324 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -17,6 +17,10 @@ import { AppComponent } from './app.component'; class MockTuiRootComponent {} describe('AppComponent', () => { + let telegramServiceSpy: jasmine.SpyObj; + let themeServiceSpy: jasmine.SpyObj; + let swUpdateServiceSpy: jasmine.SpyObj; + beforeEach(async () => { const mockWebApp = { ready: jasmine.createSpy('ready'), @@ -24,13 +28,17 @@ describe('AppComponent', () => { expand: jasmine.createSpy('expand'), }; - const telegramServiceSpy = jasmine.createSpyObj('TelegramService', [], { + telegramServiceSpy = jasmine.createSpyObj('TelegramService', [], { webApp: mockWebApp, }); - const themeServiceSpy = jasmine.createSpyObj('ThemeService', ['toggleTheme', 'isDark'], { - isDark: jasmine.createSpy('isDark').and.returnValue(false), - }); + themeServiceSpy = jasmine.createSpyObj( + 'ThemeService', + ['toggleTheme', 'isDark', 'initialize'], + { + isDark: jasmine.createSpy('isDark').and.returnValue(false), + }, + ); const authServiceSpy = jasmine.createSpyObj('AuthService', ['initFromStorage'], { isAuthenticated: jasmine.createSpy('isAuthenticated').and.returnValue(false), @@ -38,7 +46,7 @@ describe('AppComponent', () => { error: jasmine.createSpy('error').and.returnValue(null), }); - const swUpdateServiceSpy = jasmine.createSpyObj('SwUpdateService', ['checkForUpdate']); + swUpdateServiceSpy = jasmine.createSpyObj('SwUpdateService', ['checkForUpdate', 'init']); const userStoreSpy = jasmine.createSpyObj('UserStoreService', ['clearUser'], { user: jasmine.createSpy().and.returnValue(null), @@ -67,4 +75,15 @@ describe('AppComponent', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); + + it('should initialize all services on ngOnInit', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + + app.ngOnInit(); + + expect(telegramServiceSpy.webApp.ready).toHaveBeenCalled(); + expect(themeServiceSpy.initialize).toHaveBeenCalled(); + expect(swUpdateServiceSpy.init).toHaveBeenCalled(); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b052a06..a3c6f2f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { TuiRoot } from '@taiga-ui/core'; -import { TelegramService, ThemeService } from '@/shared'; +import { TelegramService, ThemeService, SwUpdateService } from '@/shared'; import { NavigationComponent } from '@/widgets'; import type { OnInit } from '@angular/core'; @@ -16,9 +16,11 @@ import type { OnInit } from '@angular/core'; export class AppComponent implements OnInit { private readonly telegramService = inject(TelegramService); private readonly themeService = inject(ThemeService); + private readonly swUpdateService = inject(SwUpdateService); ngOnInit(): void { this.telegramService.webApp.ready(); this.themeService.initialize(); + this.swUpdateService.init(); } } diff --git a/src/app/interceptors/auth.interceptor.spec.ts b/src/app/interceptors/auth.interceptor.spec.ts index 05640e0..5525023 100644 --- a/src/app/interceptors/auth.interceptor.spec.ts +++ b/src/app/interceptors/auth.interceptor.spec.ts @@ -2,10 +2,12 @@ import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { of } from 'rxjs'; import { env } from '@/environments/env'; import { AuthService } from '@/features/auth'; import { configureZonelessTestingModule } from '@/test-setup'; import { authInterceptor } from './auth.interceptor'; +import { TokenRefreshManager } from './token-refresh-manager'; describe('authInterceptor', () => { let http: HttpClient; @@ -72,4 +74,38 @@ describe('authInterceptor', () => { const req = httpMock.expectOne('/api/protected'); req.flush(null, { status: 500, statusText: 'Internal Server Error' }); }); + + it('should navigate to login on 401 error when refresh fails', (done) => { + const router = TestBed.inject(Router) as jasmine.SpyObj; + authService.getAccessToken.and.returnValue('test-token'); + authService.refreshToken$.and.returnValue(of(false)); + + http.get('/api/protected').subscribe({ + next: () => {}, + error: () => { + expect(router.navigate).toHaveBeenCalledWith(['/login']); + done(); + }, + }); + + const req = httpMock.expectOne('/api/protected'); + req.flush(null, { status: 401, statusText: 'Unauthorized' }); + }); + + it('should queue request when refresh is already in progress', () => { + const refreshManager = TokenRefreshManager.getInstance(); + const addPendingRequestSpy = spyOn(refreshManager, 'addPendingRequest').and.callThrough(); + + authService.getAccessToken.and.returnValue('test-token'); + authService.refreshToken$.and.returnValue(of(true)); + + refreshManager.setRefreshInProgress(true); + + http.get('/api/queued').subscribe(); + + const req = httpMock.expectOne('/api/queued'); + req.flush(null, { status: 401, statusText: 'Unauthorized' }); + + expect(addPendingRequestSpy).toHaveBeenCalledWith(jasmine.any(Function)); + }); }); diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts index 6305749..e5e217c 100644 --- a/src/app/interceptors/auth.interceptor.ts +++ b/src/app/interceptors/auth.interceptor.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; -import { catchError, switchMap, throwError, defer, finalize } from 'rxjs'; +import { catchError, switchMap, throwError, defer, finalize, Observable } from 'rxjs'; import { AuthService } from '@/features/auth'; import { TokenRefreshManager } from './token-refresh-manager'; @@ -11,7 +11,6 @@ import type { HttpErrorResponse, HttpEvent, } from '@angular/common/http'; -import type { Observable } from 'rxjs'; const AUTH_ENDPOINTS = ['/v1/auth/login', '/v1/auth/register', '/v1/auth/refresh'] as const; const shouldSkipAuth = (req: HttpRequest): boolean => { @@ -32,9 +31,10 @@ const handle401Error = ( if (refreshManager.isRefreshInProgress) { return defer(() => { - return new Promise>>((resolve) => { + return new Observable>>((subscriber) => { refreshManager.addPendingRequest(() => { - resolve(next(req)); + subscriber.next(next(req)); + subscriber.complete(); }); }); }).pipe(switchMap((observable) => observable)); diff --git a/src/shared/services/sw-update/sw-update.service.spec.ts b/src/shared/services/sw-update/sw-update.service.spec.ts index 5879c5e..f9428a1 100644 --- a/src/shared/services/sw-update/sw-update.service.spec.ts +++ b/src/shared/services/sw-update/sw-update.service.spec.ts @@ -71,4 +71,20 @@ describe('SwUpdateService', () => { expect(service).toBeTruthy(); expect(swUpdateSpy.isEnabled).toBe(true); }); + + it('should initialize service', () => { + service = TestBed.inject(SwUpdateService); + expect(() => service.init()).not.toThrow(); + }); + + it('should handle init when service worker is disabled', () => { + const disabledSwUpdateSpy = jasmine.createSpyObj('SwUpdate', ['checkForUpdate'], { + isEnabled: false, + versionUpdates: versionUpdatesSubject.asObservable(), + }); + + TestBed.overrideProvider(SwUpdate, { useValue: disabledSwUpdateSpy }); + service = TestBed.inject(SwUpdateService); + expect(() => service.init()).not.toThrow(); + }); }); diff --git a/src/shared/services/sw-update/sw-update.service.ts b/src/shared/services/sw-update/sw-update.service.ts index 644d889..c12c648 100644 --- a/src/shared/services/sw-update/sw-update.service.ts +++ b/src/shared/services/sw-update/sw-update.service.ts @@ -11,7 +11,7 @@ export class SwUpdateService { private readonly swUpdate = inject(SwUpdate); private readonly destroyRef = inject(DestroyRef); - constructor() { + init(): void { if (this.swUpdate.isEnabled) { this.initializeUpdateChecks(); this.handleUpdates(); diff --git a/src/shared/services/theme/theme.service.spec.ts b/src/shared/services/theme/theme.service.spec.ts index 4db320b..0b00c27 100644 --- a/src/shared/services/theme/theme.service.spec.ts +++ b/src/shared/services/theme/theme.service.spec.ts @@ -1,12 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; +import { ThemeService } from '@/shared'; import { UserApiService } from '@/shared/services/user'; import { configureZonelessTestingModule } from '@/test-setup'; -import { ThemeService } from './theme.service'; describe('ThemeService', () => { let service: ThemeService; - let userApiService: jasmine.SpyObj; beforeEach((): void => { const matchMediaMock = jasmine.createSpy('matchMedia').and.returnValue({ @@ -37,7 +36,6 @@ describe('ThemeService', () => { }); service = TestBed.inject(ThemeService); - userApiService = TestBed.inject(UserApiService) as jasmine.SpyObj; }); it('should be created', (): void => { @@ -71,109 +69,52 @@ describe('ThemeService', () => { expect(() => service.initialize()).not.toThrow(); }); - describe('setTheme with syncWithServer parameter', () => { - it('should sync with server when syncWithServer is true (default)', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'dark' }), - ); + describe('theme color management', () => { + it('should update theme color meta tag for dark theme', () => { + const mockMeta = document.createElement('meta'); + mockMeta.setAttribute('name', 'theme-color'); + document.head.appendChild(mockMeta); service.setTheme('dark'); - setTimeout(() => { - expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'dark' }); - done(); - }, 550); - }); - - it('should not sync with server when syncWithServer is false', () => { - service.setTheme('dark', false); - - expect(userApiService.updateTheme$).not.toHaveBeenCalled(); - }); + expect(mockMeta.getAttribute('content')).toBe('#0f172a'); - it('should still apply theme locally when syncWithServer is false', () => { - service.setTheme('dark', false); - - expect(service.theme()).toBe('dark'); + document.head.removeChild(mockMeta); }); - }); - describe('debounce functionality', () => { - it('should debounce multiple rapid theme changes', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'dark' }), - ); + it('should update theme color meta tag for light theme', () => { + const mockMeta = document.createElement('meta'); + mockMeta.setAttribute('name', 'theme-color'); + document.head.appendChild(mockMeta); service.setTheme('light'); - service.setTheme('dark'); - service.setTheme('light'); - service.setTheme('dark'); - - expect(userApiService.updateTheme$).not.toHaveBeenCalled(); - - setTimeout(() => { - expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); - expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'dark' }); - done(); - }, 550); - }); - it('should not call API for duplicate theme changes', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'dark' }), - ); - - service.setTheme('dark'); - service.setTheme('dark'); - service.setTheme('dark'); + expect(mockMeta.getAttribute('content')).toBe('#ffffff'); - setTimeout(() => { - expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); - done(); - }, 550); + document.head.removeChild(mockMeta); }); - it('should handle rapid theme changes with distinctUntilChanged', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'light' }), - ); - - service.setTheme('light'); - service.setTheme('dark'); - service.setTheme('light'); - - setTimeout(() => { - expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); - expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'light' }); - done(); - }, 550); + it('should handle missing theme color meta tag', () => { + expect(() => { + service.setTheme('dark'); + }).not.toThrow(); }); }); - describe('server synchronization', () => { - it('should call updateTheme$ when setTheme is called with default parameters', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'light' }), - ); - - service.setTheme('light'); + describe('localStorage handling', () => { + it('should handle localStorage errors gracefully', () => { + spyOn(localStorage, 'getItem').and.throwError('Storage error'); - setTimeout(() => { - expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'light' }); - done(); - }, 550); + expect(() => { + service.setTheme('dark'); + }).not.toThrow(); }); - it('should handle API errors gracefully', (done: DoneFn) => { - userApiService.updateTheme$.and.returnValue( - of({ message: 'Theme updated successfully', theme: 'dark' }), - ); + it('should handle localStorage setItem errors', () => { + spyOn(localStorage, 'setItem').and.throwError('Storage error'); expect(() => { - service.setTheme('dark'); - setTimeout(() => { - done(); - }, 550); + service.setTheme('light'); }).not.toThrow(); }); }); diff --git a/src/shared/services/user/user-store.service.ts b/src/shared/services/user/user-store.service.ts index 186c332..1aaa017 100644 --- a/src/shared/services/user/user-store.service.ts +++ b/src/shared/services/user/user-store.service.ts @@ -1,8 +1,8 @@ import { inject, Injectable, signal, computed } from '@angular/core'; import { tap, catchError, of, map } from 'rxjs'; +import { UserApiService } from '@/shared'; import type { User } from '@/shared/lib/types'; import { ThemeService } from '@/shared/services/theme'; -import { UserApiService } from './user-api.service'; import type { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' })