Files
teamwallet/myteamwallet_frontend_modern/docs/superpowers/plans/2026-07-31-foundation-app-shell.md
Bastian Wagner 6bea4f766a first commit
2026-07-31 21:02:47 +02:00

56 KiB
Raw Permalink Blame History

Foundation & App Shell Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up the shared infrastructure for the TeamWallet frontend rebuild — Angular Material theming, PWA scaffolding, environment config, domain models, auth state/API/guards, HTTP interceptors, a working login screen, and the app shell with bottom navigation — so a user can log in against the real backend and land in a navigable (still mostly stubbed) team area.

Architecture: Standalone Angular 21 components throughout, no NgModules. App-wide auth state lives in an injectable AuthStore built on signals. Guards and HTTP interceptors are plain functions (CanActivateFn, HttpInterceptorFn). Feature routes are lazy-loaded via loadComponent. This plan builds only the shared shell and stub routes; later plans (Auth extras, Team-Select, Overview, Members, Cashbox, More, Public-Team) fill in the real feature screens behind these stubs.

Tech Stack: Angular 21 (standalone, zoneless, esbuild builder), Angular Material 21 + CDK, @angular/service-worker (PWA), native Angular Signals, Reactive Forms, Vitest (@angular/build:unit-test).

Global Constraints

  • Angular 21, standalone components only — no NgModules, no standalone: true flag needed (it's the default).
  • Angular Material 21 is the UI library; no Bootstrap/PrimeNG.
  • State management is native Signals in injectable services — no NgRx.
  • Guards and interceptors are functional (CanActivateFn, HttpInterceptorFn) — no class-based guards/interceptors.
  • No i18n layer — German text is hardcoded directly in templates.
  • PWA is required (@angular/service-worker, installable manifest).
  • Backend base URL pattern: {environment.apiUrl}<controller>/<path>, e.g. {apiUrl}auth/email/login. Dev backend runs at http://localhost:3999/api/v1/.
  • Tests use Vitest via the @angular/build:unit-test builder; describe/it/expect/vi are globals (see existing src/app/app.spec.ts — no test-framework imports needed).
  • File/class naming follows this repo's existing scaffold convention (confirmed via ng generate dry-runs): components live in their own folder as <name>/<name>.ts (class PascalCase, no Component suffix), services as <name>.ts (class no Service suffix), guards as <name>-guard.ts (const <name>Guard), interceptors as <name>-interceptor.ts (const <name>Interceptor).
  • This app is zoneless (no zone.js dependency) — tests must use async/await with Promise-returning APIs (e.g. await router.navigateByUrl(...)), never fakeAsync/tick.

File Structure (produced by this plan)

src/environments/
  environment.ts                  # prod apiUrl
  environment.development.ts      # dev apiUrl (localhost:3999)
src/app/
  models/
    role.model.ts
    status.model.ts
    user.model.ts
    team-role.model.ts            # TeamRole enum + canBook/canInvite
  core/
    auth/
      auth-store.ts                # signal-based session state
      auth-api.ts                  # login/me HTTP calls
      auth-guard.ts                # protects authenticated routes
      root-redirect-guard.ts       # '/' -> /team-select or /auth/login
    http/
      auth-interceptor.ts          # attaches Bearer token
      error-interceptor.ts         # 401 -> logout + redirect + snackbar
    layout/
      shell/shell.ts               # header + bottom nav, wraps /team/:id/*
      not-found/not-found.ts
  features/
    auth/login/login.ts
    team-select/team-select.ts     # stub, real impl in a later plan
    team/
      overview/overview.ts         # stub
      members/members.ts           # stub
      cashbox/cashbox.ts           # stub
      more/more.ts                 # stub
  app.ts / app.html / app.spec.ts  # trimmed to <router-outlet/>
  app.config.ts                    # Material, PWA, HTTP, router providers
  app.routes.ts
  app.routes.spec.ts

Task 1: Angular Material Install & Custom Theme

Files:

  • Modify: package.json, package-lock.json (via CLI, not hand-edited)
  • Modify: src/styles.scss
  • Modify: src/index.html

Interfaces:

  • Produces: Material component modules available for import throughout the app (@angular/material/*); a global M3 theme using mat.$green-palette (primary) and mat.$orange-palette (tertiary) to match the "frisch/sportlich" direction from the design spec.

  • Step 1: Run the Material schematic

Run in myteamwallet_frontend_modern/:

npx ng add @angular/material --skip-confirmation --theme=custom --typography=true --animations=enabledAsync

Expected: package.json/package-lock.json gain @angular/material and @angular/cdk (^21.2.x); src/styles.scss is rewritten with a @use '@angular/material' as mat; block and a mat.theme(...) include; src/index.html gains Roboto + Material Icons font <link> tags.

  • Step 2: Swap in the custom palette

Edit src/styles.scss — replace the generated color block (which defaults to mat.$azure-palette / mat.$blue-palette) with:

@include mat.theme(
  (
    color: (
      primary: mat.$green-palette,
      tertiary: mat.$orange-palette,
    ),
    typography: Roboto,
    density: 0,
  )
);

Keep the rest of the generated file (the body rule with --mat-sys-* variables) as-is.

  • Step 3: Verify the build picks up the theme

Run: npm run build -- --configuration development Expected: build succeeds with no Sass errors; dist/ output contains compiled CSS referencing --mat-sys-primary custom properties.

  • Step 4: Commit
git add package.json package-lock.json src/styles.scss src/index.html
git commit -m "chore: add Angular Material with a green/orange M3 theme"

Task 2: PWA Setup

Files:

  • Modify: package.json, package-lock.json, angular.json, src/app/app.config.ts, src/index.html
  • Create: ngsw-config.json, public/manifest.webmanifest, public/icons/*.png

Interfaces:

  • Produces: provideServiceWorker(...) registered in app.config.ts providers (later tasks append to the same providers array, don't replace it).

  • Step 1: Run the PWA schematic

npx ng add @angular/pwa --skip-confirmation

Expected: @angular/service-worker added to package.json; ngsw-config.json created; public/manifest.webmanifest + public/icons/*.png created; angular.json's production build configuration gains "serviceWorker": "ngsw-config.json"; src/index.html gains <link rel="manifest" href="manifest.webmanifest"> and a <noscript> tag; src/app/app.config.ts gains a provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' }) entry in providers.

  • Step 2: Brand the manifest

Edit public/manifest.webmanifest — change the top of the file to:

{
  "name": "TeamWallet",
  "short_name": "TeamWallet",
  "theme_color": "#2e7d32",
  "background_color": "#ffffff",
  "display": "standalone",

Keep scope, start_url, and the icons array exactly as generated.

  • Step 3: Verify a production build

Run: npm run build Expected: build succeeds; dist/myteamwallet_frontend_modern/browser/ngsw.json exists.

  • Step 4: Commit
git add package.json package-lock.json angular.json ngsw-config.json public/manifest.webmanifest public/icons src/app/app.config.ts src/index.html
git commit -m "chore: add PWA support (service worker, manifest, icons)"

Task 3: Environment Configuration

Files:

  • Create: src/environments/environment.ts
  • Create: src/environments/environment.development.ts
  • Modify: angular.json:51-55 (the build target's development configuration)

Interfaces:

  • Produces: environment: { production: boolean; apiUrl: string }, importable as import { environment } from '../../environments/environment' (path depends on the importing file's depth — see later tasks for exact relative paths).

  • Step 1: Create the production environment file

Create src/environments/environment.ts:

export const environment = {
  production: true,
  apiUrl: 'https://myteamwallet.de/api/v1/',
};
  • Step 2: Create the development environment file

Create src/environments/environment.development.ts:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3999/api/v1/',
};
  • Step 3: Wire up the file replacement

In angular.json, find the build target's configurations.development block:

            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true
            }

Replace with:

            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
  • Step 4: Verify both configurations build

Run: npm run build -- --configuration development then npm run build -- --configuration production Expected: both succeed with no errors.

  • Step 5: Commit
git add src/environments angular.json
git commit -m "feat: add dev/prod environment configuration with backend apiUrl"

Task 4: Core Domain Models & Permission Helpers

Files:

  • Create: src/app/models/role.model.ts
  • Create: src/app/models/status.model.ts
  • Create: src/app/models/user.model.ts
  • Create: src/app/models/team-role.model.ts
  • Test: src/app/models/team-role.model.spec.ts

Interfaces:

  • Produces:

    • interface Role { id: number; name?: string }
    • enum RoleId { Admin = 1, User = 2 }
    • interface Status { id: number; name?: string }
    • interface UserPhoto { id: string; path: string }
    • interface User { id: number; email: string | null; firstName: string | null; lastName: string | null; role?: Role | null; status?: Status; photo?: UserPhoto | null }
    • enum TeamRole { Player = 1, ScndTreasurer = 2, Captain = 3, Treasurer = 4, Coach = 5 }
    • function canBook(role: TeamRole): boolean
    • function canInvite(role: TeamRole): boolean
  • Step 1: Write the failing test for permission helpers

Create src/app/models/team-role.model.spec.ts:

import { TeamRole, canBook, canInvite } from './team-role.model';

describe('team-role permissions', () => {
  it('canBook is false for Player', () => {
    expect(canBook(TeamRole.Player)).toBe(false);
  });

  it('canBook is true from ScndTreasurer upwards', () => {
    expect(canBook(TeamRole.ScndTreasurer)).toBe(true);
    expect(canBook(TeamRole.Captain)).toBe(true);
    expect(canBook(TeamRole.Treasurer)).toBe(true);
    expect(canBook(TeamRole.Coach)).toBe(true);
  });

  it('canInvite is false for Player and ScndTreasurer', () => {
    expect(canInvite(TeamRole.Player)).toBe(false);
    expect(canInvite(TeamRole.ScndTreasurer)).toBe(false);
  });

  it('canInvite is true above ScndTreasurer', () => {
    expect(canInvite(TeamRole.Captain)).toBe(true);
    expect(canInvite(TeamRole.Treasurer)).toBe(true);
    expect(canInvite(TeamRole.Coach)).toBe(true);
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/team-role.model.spec.ts' Expected: FAIL — team-role.model.ts does not exist yet.

  • Step 3: Create the supporting model files

Create src/app/models/role.model.ts:

export interface Role {
  id: number;
  name?: string;
}

export enum RoleId {
  Admin = 1,
  User = 2,
}

Create src/app/models/status.model.ts:

export interface Status {
  id: number;
  name?: string;
}

Create src/app/models/user.model.ts:

import { Role } from './role.model';
import { Status } from './status.model';

export interface UserPhoto {
  id: string;
  path: string;
}

export interface User {
  id: number;
  email: string | null;
  firstName: string | null;
  lastName: string | null;
  role?: Role | null;
  status?: Status;
  photo?: UserPhoto | null;
}
  • Step 4: Implement the permission helpers

Create src/app/models/team-role.model.ts:

export enum TeamRole {
  Player = 1,
  ScndTreasurer = 2,
  Captain = 3,
  Treasurer = 4,
  Coach = 5,
}

export function canBook(role: TeamRole): boolean {
  return role >= TeamRole.ScndTreasurer;
}

export function canInvite(role: TeamRole): boolean {
  return role > TeamRole.ScndTreasurer;
}
  • Step 5: Run the test to verify it passes

Run: npm test -- --include '**/team-role.model.spec.ts' Expected: PASS (4 tests).

  • Step 6: Commit
git add src/app/models
git commit -m "feat: add core domain models and team-role permission helpers"

Task 5: AuthStore (Session State)

Files:

  • Create: src/app/core/auth/auth-store.ts
  • Test: src/app/core/auth/auth-store.spec.ts

Interfaces:

  • Consumes: User from ../../models/user.model.

  • Produces: class AuthStore (providedIn: 'root') with:

    • readonly token: Signal<string | null>
    • readonly currentUser: Signal<User | null>
    • readonly isLoggedIn: Signal<boolean>
    • setSession(token: string, user: User): void
    • clearSession(): void
    • persists to localStorage under keys tw_token / tw_user.
  • Step 1: Write the failing test

Create src/app/core/auth/auth-store.spec.ts:

import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth-store';

describe('AuthStore', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('starts logged out when nothing is stored', () => {
    TestBed.configureTestingModule({});
    const store = TestBed.inject(AuthStore);

    expect(store.isLoggedIn()).toBe(false);
    expect(store.currentUser()).toBeNull();
  });

  it('stores the session and exposes it as logged in', () => {
    TestBed.configureTestingModule({});
    const store = TestBed.inject(AuthStore);
    const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };

    store.setSession('jwt-token', user);

    expect(store.isLoggedIn()).toBe(true);
    expect(store.token()).toBe('jwt-token');
    expect(store.currentUser()).toEqual(user);
    expect(localStorage.getItem('tw_token')).toBe('jwt-token');
  });

  it('restores the session from localStorage on creation', () => {
    localStorage.setItem('tw_token', 'stored-token');
    localStorage.setItem(
      'tw_user',
      JSON.stringify({ id: 2, email: 'c@d.de', firstName: 'C', lastName: 'D' }),
    );

    TestBed.configureTestingModule({});
    const store = TestBed.inject(AuthStore);

    expect(store.isLoggedIn()).toBe(true);
    expect(store.token()).toBe('stored-token');
    expect(store.currentUser()?.email).toBe('c@d.de');
  });

  it('clears the session', () => {
    TestBed.configureTestingModule({});
    const store = TestBed.inject(AuthStore);
    store.setSession('jwt-token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });

    store.clearSession();

    expect(store.isLoggedIn()).toBe(false);
    expect(store.currentUser()).toBeNull();
    expect(localStorage.getItem('tw_token')).toBeNull();
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/auth-store.spec.ts' Expected: FAIL — auth-store.ts does not exist yet.

  • Step 3: Implement AuthStore

Create src/app/core/auth/auth-store.ts:

import { Injectable, computed, signal } from '@angular/core';
import { User } from '../../models/user.model';

@Injectable({ providedIn: 'root' })
export class AuthStore {
  private static readonly TOKEN_KEY = 'tw_token';
  private static readonly USER_KEY = 'tw_user';

  private readonly tokenSignal = signal<string | null>(
    localStorage.getItem(AuthStore.TOKEN_KEY),
  );
  private readonly userSignal = signal<User | null>(AuthStore.readStoredUser());

  readonly token = this.tokenSignal.asReadonly();
  readonly currentUser = this.userSignal.asReadonly();
  readonly isLoggedIn = computed(() => this.tokenSignal() !== null);

  setSession(token: string, user: User): void {
    localStorage.setItem(AuthStore.TOKEN_KEY, token);
    localStorage.setItem(AuthStore.USER_KEY, JSON.stringify(user));
    this.tokenSignal.set(token);
    this.userSignal.set(user);
  }

  clearSession(): void {
    localStorage.removeItem(AuthStore.TOKEN_KEY);
    localStorage.removeItem(AuthStore.USER_KEY);
    this.tokenSignal.set(null);
    this.userSignal.set(null);
  }

  private static readStoredUser(): User | null {
    const raw = localStorage.getItem(AuthStore.USER_KEY);
    return raw ? (JSON.parse(raw) as User) : null;
  }
}
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/auth-store.spec.ts' Expected: PASS (4 tests).

  • Step 5: Commit
git add src/app/core/auth/auth-store.ts src/app/core/auth/auth-store.spec.ts
git commit -m "feat: add signal-based AuthStore for session state"

Task 6: HTTP Interceptors (Auth + Error)

Files:

  • Create: src/app/core/http/auth-interceptor.ts
  • Test: src/app/core/http/auth-interceptor.spec.ts
  • Create: src/app/core/http/error-interceptor.ts
  • Test: src/app/core/http/error-interceptor.spec.ts
  • Modify: src/app/app.config.ts

Interfaces:

  • Consumes: AuthStore from ../auth/auth-store.

  • Produces: authInterceptor: HttpInterceptorFn, errorInterceptor: HttpInterceptorFn, both registered via provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])) in app.config.ts.

  • Step 1: Write the failing test for the auth interceptor

Create src/app/core/http/auth-interceptor.spec.ts:

import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { authInterceptor } from './auth-interceptor';
import { AuthStore } from '../auth/auth-store';

describe('authInterceptor', () => {
  let httpMock: HttpTestingController;
  let httpClient: HttpClient;
  let authStore: AuthStore;

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({
      providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
    });
    httpMock = TestBed.inject(HttpTestingController);
    httpClient = TestBed.inject(HttpClient);
    authStore = TestBed.inject(AuthStore);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('attaches the bearer token when a session exists', () => {
    authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });

    httpClient.get('/ping').subscribe();

    const request = httpMock.expectOne('/ping');
    expect(request.request.headers.get('Authorization')).toBe('Bearer abc123');
    request.flush({});
  });

  it('does not attach a header when no session exists', () => {
    httpClient.get('/ping').subscribe();

    const request = httpMock.expectOne('/ping');
    expect(request.request.headers.has('Authorization')).toBe(false);
    request.flush({});
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/auth-interceptor.spec.ts' Expected: FAIL — auth-interceptor.ts does not exist yet.

  • Step 3: Implement the auth interceptor

Create src/app/core/http/auth-interceptor.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../auth/auth-store';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authStore = inject(AuthStore);
  const token = authStore.token();

  if (!token) {
    return next(req);
  }

  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/auth-interceptor.spec.ts' Expected: PASS (2 tests).

  • Step 5: Write the failing test for the error interceptor

Create src/app/core/http/error-interceptor.spec.ts:

import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { errorInterceptor } from './error-interceptor';
import { AuthStore } from '../auth/auth-store';

describe('errorInterceptor', () => {
  let httpMock: HttpTestingController;
  let httpClient: HttpClient;
  let authStore: AuthStore;
  let router: Router;

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([errorInterceptor])),
        provideHttpClientTesting(),
        provideRouter([]),
        provideAnimationsAsync(),
      ],
    });
    httpMock = TestBed.inject(HttpTestingController);
    httpClient = TestBed.inject(HttpClient);
    authStore = TestBed.inject(AuthStore);
    router = TestBed.inject(Router);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('clears the session and redirects to login on a 401 response', () => {
    authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
    const navigateSpy = vi.spyOn(router, 'navigate');

    httpClient.get('/secure').subscribe({ error: () => undefined });
    httpMock.expectOne('/secure').flush('unauthorized', { status: 401, statusText: 'Unauthorized' });

    expect(authStore.isLoggedIn()).toBe(false);
    expect(navigateSpy).toHaveBeenCalledWith(['/auth/login']);
  });

  it('leaves other error statuses untouched', () => {
    httpClient.get('/secure').subscribe({ error: () => undefined });
    httpMock.expectOne('/secure').flush('server error', { status: 500, statusText: 'Server Error' });

    expect(authStore.isLoggedIn()).toBe(false);
  });
});
  • Step 6: Run the test to verify it fails

Run: npm test -- --include '**/error-interceptor.spec.ts' Expected: FAIL — error-interceptor.ts does not exist yet.

  • Step 7: Implement the error interceptor

Create src/app/core/http/error-interceptor.ts:

import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthStore } from '../auth/auth-store';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const authStore = inject(AuthStore);
  const router = inject(Router);
  const snackBar = inject(MatSnackBar);

  return next(req).pipe(
    catchError((error: unknown) => {
      if (error instanceof HttpErrorResponse && error.status === 401) {
        authStore.clearSession();
        void router.navigate(['/auth/login']);
        snackBar.open('Sitzung abgelaufen. Bitte erneut anmelden.', 'OK', { duration: 5000 });
      }
      return throwError(() => error);
    }),
  );
};
  • Step 8: Run the test to verify it passes

Run: npm test -- --include '**/error-interceptor.spec.ts' Expected: PASS (2 tests).

  • Step 9: Wire the interceptors, HttpClient, and animations into app.config.ts

ng add @angular/material (Task 1) does not register provideAnimationsAsync() in app.config.ts — it only rewrites styles.scss/index.html. Verify this is still missing (grep -n "provideAnimationsAsync" src/app/app.config.ts should find nothing), then edit src/app/app.config.ts to its final Foundation-plan state, adding it together with the HTTP client:

import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideServiceWorker } from '@angular/service-worker';

import { routes } from './app.routes';
import { authInterceptor } from './core/http/auth-interceptor';
import { errorInterceptor } from './core/http/error-interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideAnimationsAsync(),
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    provideServiceWorker('ngsw-worker.js', {
      enabled: !isDevMode(),
      registrationStrategy: 'registerWhenStable:30000',
    }),
  ],
};
  • Step 10: Verify the full build still succeeds

Run: npm run build -- --configuration development Expected: no compile errors.

  • Step 11: Commit
git add src/app/core/http src/app/app.config.ts
git commit -m "feat: add auth and error HTTP interceptors"

Task 7: AuthApi (Login + Current User)

Files:

  • Create: src/app/core/auth/auth-api.ts
  • Test: src/app/core/auth/auth-api.spec.ts

Interfaces:

  • Consumes: environment from ../../../environments/environment, User from ../../models/user.model.

  • Produces: interface LoginResponse { token: string; user: User }, class AuthApi (providedIn: 'root') with:

    • login(email: string, password: string): Observable<LoginResponse>POST {apiUrl}auth/email/login
    • me(): Observable<User>GET {apiUrl}auth/me
  • Step 1: Write the failing test

Create src/app/core/auth/auth-api.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthApi } from './auth-api';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';

describe('AuthApi', () => {
  let service: AuthApi;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(AuthApi);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('posts credentials to the login endpoint', () => {
    const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };

    service.login('a@b.de', 'secret').subscribe((response) => {
      expect(response).toEqual({ token: 'jwt-token', user });
    });

    const request = httpMock.expectOne(`${environment.apiUrl}auth/email/login`);
    expect(request.request.method).toBe('POST');
    expect(request.request.body).toEqual({ email: 'a@b.de', password: 'secret' });
    request.flush({ token: 'jwt-token', user });
  });

  it('fetches the current user from the me endpoint', () => {
    const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };

    service.me().subscribe((response) => {
      expect(response).toEqual(user);
    });

    const request = httpMock.expectOne(`${environment.apiUrl}auth/me`);
    expect(request.request.method).toBe('GET');
    request.flush(user);
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/auth-api.spec.ts' Expected: FAIL — auth-api.ts does not exist yet.

  • Step 3: Implement AuthApi

Create src/app/core/auth/auth-api.ts:

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';

export interface LoginResponse {
  token: string;
  user: User;
}

@Injectable({ providedIn: 'root' })
export class AuthApi {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = `${environment.apiUrl}auth`;

  login(email: string, password: string): Observable<LoginResponse> {
    return this.http.post<LoginResponse>(`${this.baseUrl}/email/login`, { email, password });
  }

  me(): Observable<User> {
    return this.http.get<User>(`${this.baseUrl}/me`);
  }
}
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/auth-api.spec.ts' Expected: PASS (2 tests).

  • Step 5: Commit
git add src/app/core/auth/auth-api.ts src/app/core/auth/auth-api.spec.ts
git commit -m "feat: add AuthApi for login and current-user requests"

Task 8: Guards (authGuard + rootRedirectGuard)

Files:

  • Create: src/app/core/auth/auth-guard.ts
  • Test: src/app/core/auth/auth-guard.spec.ts
  • Create: src/app/core/auth/root-redirect-guard.ts
  • Test: src/app/core/auth/root-redirect-guard.spec.ts

Interfaces:

  • Consumes: AuthStore from ./auth-store.

  • Produces: authGuard: CanActivateFn (blocks unauthenticated access, redirects to /auth/login), rootRedirectGuard: CanActivateFn (always redirects / to /team-select or /auth/login based on session state).

  • Step 1: Write the failing test for authGuard

Create src/app/core/auth/auth-guard.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { authGuard } from './auth-guard';
import { AuthStore } from './auth-store';

describe('authGuard', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [provideRouter([])] });
  });

  it('allows navigation when logged in', () => {
    const authStore = TestBed.inject(AuthStore);
    authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });

    const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));

    expect(result).toBe(true);
  });

  it('redirects to login when logged out', () => {
    const router = TestBed.inject(Router);

    const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));

    expect(result).toEqual(router.parseUrl('/auth/login'));
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/auth-guard.spec.ts' Expected: FAIL — auth-guard.ts does not exist yet.

  • Step 3: Implement authGuard

Create src/app/core/auth/auth-guard.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';

export const authGuard: CanActivateFn = () => {
  const authStore = inject(AuthStore);
  const router = inject(Router);

  return authStore.isLoggedIn() ? true : router.parseUrl('/auth/login');
};
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/auth-guard.spec.ts' Expected: PASS (2 tests).

  • Step 5: Write the failing test for rootRedirectGuard

Create src/app/core/auth/root-redirect-guard.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { rootRedirectGuard } from './root-redirect-guard';
import { AuthStore } from './auth-store';

describe('rootRedirectGuard', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [provideRouter([])] });
  });

  it('redirects to team-select when logged in', () => {
    const authStore = TestBed.inject(AuthStore);
    const router = TestBed.inject(Router);
    authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });

    const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));

    expect(result).toEqual(router.parseUrl('/team-select'));
  });

  it('redirects to login when logged out', () => {
    const router = TestBed.inject(Router);

    const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));

    expect(result).toEqual(router.parseUrl('/auth/login'));
  });
});
  • Step 6: Run the test to verify it fails

Run: npm test -- --include '**/root-redirect-guard.spec.ts' Expected: FAIL — root-redirect-guard.ts does not exist yet.

  • Step 7: Implement rootRedirectGuard

Create src/app/core/auth/root-redirect-guard.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';

export const rootRedirectGuard: CanActivateFn = () => {
  const authStore = inject(AuthStore);
  const router = inject(Router);

  return router.parseUrl(authStore.isLoggedIn() ? '/team-select' : '/auth/login');
};
  • Step 8: Run the test to verify it passes

Run: npm test -- --include '**/root-redirect-guard.spec.ts' Expected: PASS (2 tests).

  • Step 9: Commit
git add src/app/core/auth/auth-guard.ts src/app/core/auth/auth-guard.spec.ts src/app/core/auth/root-redirect-guard.ts src/app/core/auth/root-redirect-guard.spec.ts
git commit -m "feat: add authGuard and rootRedirectGuard"

Task 9: Login Feature

Files:

  • Create: src/app/features/auth/login/login.ts
  • Create: src/app/features/auth/login/login.html
  • Create: src/app/features/auth/login/login.scss
  • Test: src/app/features/auth/login/login.spec.ts

Interfaces:

  • Consumes: AuthApi from ../../../core/auth/auth-api, AuthStore from ../../../core/auth/auth-store.

  • Produces: class Login, selector app-login, standalone component with a reactive email/password form; on success calls authStore.setSession(token, user) and navigates to /team-select; on failure sets a visible error message.

  • Step 1: Write the failing test

Create src/app/features/auth/login/login.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { Login } from './login';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../../core/auth/auth-store';

describe('Login', () => {
  let httpMock: HttpTestingController;
  let router: Router;
  let authStore: AuthStore;

  beforeEach(async () => {
    localStorage.clear();
    await TestBed.configureTestingModule({
      imports: [Login],
      providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([]), provideAnimationsAsync()],
    }).compileComponents();

    httpMock = TestBed.inject(HttpTestingController);
    router = TestBed.inject(Router);
    authStore = TestBed.inject(AuthStore);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('logs in and navigates to team-select on success', () => {
    const fixture = TestBed.createComponent(Login);
    const navigateSpy = vi.spyOn(router, 'navigate');
    fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'secret' });
    fixture.componentInstance['onSubmit']();

    const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
    httpMock.expectOne(`${environment.apiUrl}auth/email/login`).flush({ token: 'jwt-token', user });

    expect(authStore.isLoggedIn()).toBe(true);
    expect(navigateSpy).toHaveBeenCalledWith(['/team-select']);
  });

  it('shows an error message when login fails', () => {
    const fixture = TestBed.createComponent(Login);
    fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'wrong' });
    fixture.componentInstance['onSubmit']();

    httpMock
      .expectOne(`${environment.apiUrl}auth/email/login`)
      .flush('unauthorized', { status: 401, statusText: 'Unauthorized' });

    expect(fixture.componentInstance['errorMessage']()).toBe(
      'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.',
    );
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/login.spec.ts' Expected: FAIL — login.ts does not exist yet.

  • Step 3: Implement the Login component

Create src/app/features/auth/login/login.ts:

import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthApi } from '../../../core/auth/auth-api';
import { AuthStore } from '../../../core/auth/auth-store';

@Component({
  selector: 'app-login',
  imports: [
    ReactiveFormsModule,
    MatCardModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    MatProgressSpinnerModule,
  ],
  templateUrl: './login.html',
  styleUrl: './login.scss',
})
export class Login {
  private readonly formBuilder = inject(FormBuilder);
  private readonly authApi = inject(AuthApi);
  private readonly authStore = inject(AuthStore);
  private readonly router = inject(Router);

  protected readonly isSubmitting = signal(false);
  protected readonly errorMessage = signal<string | null>(null);

  protected readonly form = this.formBuilder.nonNullable.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required]],
  });

  protected onSubmit(): void {
    if (this.form.invalid || this.isSubmitting()) {
      return;
    }

    this.isSubmitting.set(true);
    this.errorMessage.set(null);
    const { email, password } = this.form.getRawValue();

    this.authApi.login(email, password).subscribe({
      next: ({ token, user }) => {
        this.authStore.setSession(token, user);
        this.isSubmitting.set(false);
        void this.router.navigate(['/team-select']);
      },
      error: () => {
        this.isSubmitting.set(false);
        this.errorMessage.set('Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.');
      },
    });
  }
}

Create src/app/features/auth/login/login.html:

<div class="login-page">
  <mat-card class="login-card">
    <mat-card-header>
      <mat-card-title>TeamWallet</mat-card-title>
      <mat-card-subtitle>Anmelden</mat-card-subtitle>
    </mat-card-header>

    <mat-card-content>
      <form [formGroup]="form" (ngSubmit)="onSubmit()">
        <mat-form-field appearance="outline">
          <mat-label>E-Mail</mat-label>
          <input matInput type="email" formControlName="email" autocomplete="email" />
        </mat-form-field>

        <mat-form-field appearance="outline">
          <mat-label>Passwort</mat-label>
          <input matInput type="password" formControlName="password" autocomplete="current-password" />
        </mat-form-field>

        @if (errorMessage()) {
          <p class="login-error">{{ errorMessage() }}</p>
        }

        <button mat-flat-button color="primary" type="submit" [disabled]="form.invalid || isSubmitting()">
          @if (isSubmitting()) {
            <mat-spinner diameter="20" />
          } @else {
            Anmelden
          }
        </button>
      </form>
    </mat-card-content>
  </mat-card>
</div>

Create src/app/features/auth/login/login.scss:

.login-page {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100dvh;
  padding: 1rem;
}

.login-card {
  width: 100%;
  max-width: 360px;

  form {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }
}

.login-error {
  color: var(--mat-sys-error);
  margin: 0;
}
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/login.spec.ts' Expected: PASS (2 tests).

  • Step 5: Commit
git add src/app/features/auth/login
git commit -m "feat: add login screen"

Task 10: App Shell (Header + Bottom Navigation)

Files:

  • Create: src/app/core/layout/shell/shell.ts
  • Create: src/app/core/layout/shell/shell.html
  • Create: src/app/core/layout/shell/shell.scss
  • Test: src/app/core/layout/shell/shell.spec.ts
  • Create: src/app/core/layout/not-found/not-found.ts
  • Test: src/app/core/layout/not-found/not-found.spec.ts

Interfaces:

  • Produces: class Shell (selector app-shell) rendering a header, a <router-outlet> for the active team route, and a 4-item bottom nav (overview/members/cashbox/more, relative routerLinks). class NotFound (selector app-not-found) for the wildcard route.

  • Step 1: Write the failing test for Shell

Create src/app/core/layout/shell/shell.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Shell } from './shell';

describe('Shell', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Shell],
      providers: [provideRouter([])],
    }).compileComponents();
  });

  it('renders the bottom navigation with four tabs', () => {
    const fixture = TestBed.createComponent(Shell);
    fixture.detectChanges();

    const links = fixture.nativeElement.querySelectorAll('.shell-bottom-nav__item');
    expect(links.length).toBe(4);
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/shell.spec.ts' Expected: FAIL — shell.ts does not exist yet.

  • Step 3: Implement Shell

Create src/app/core/layout/shell/shell.ts:

import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';

@Component({
  selector: 'app-shell',
  imports: [RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatIconModule],
  templateUrl: './shell.html',
  styleUrl: './shell.scss',
})
export class Shell {}

Create src/app/core/layout/shell/shell.html:

<mat-toolbar color="primary" class="shell-header">
  <span>TeamWallet</span>
</mat-toolbar>

<main class="shell-content">
  <router-outlet />
</main>

<nav class="shell-bottom-nav">
  <a routerLink="overview" routerLinkActive="active" class="shell-bottom-nav__item">
    <mat-icon>account_balance_wallet</mat-icon>
    <span>Übersicht</span>
  </a>
  <a routerLink="members" routerLinkActive="active" class="shell-bottom-nav__item">
    <mat-icon>groups</mat-icon>
    <span>Mitglieder</span>
  </a>
  <a routerLink="cashbox" routerLinkActive="active" class="shell-bottom-nav__item">
    <mat-icon>payments</mat-icon>
    <span>Kasse</span>
  </a>
  <a routerLink="more" routerLinkActive="active" class="shell-bottom-nav__item">
    <mat-icon>more_horiz</mat-icon>
    <span>Mehr</span>
  </a>
</nav>

Create src/app/core/layout/shell/shell.scss:

:host {
  display: flex;
  flex-direction: column;
  height: 100dvh;
}

.shell-content {
  flex: 1;
  overflow-y: auto;
}

.shell-bottom-nav {
  display: flex;
  border-top: 1px solid var(--mat-sys-outline-variant);
  background: var(--mat-sys-surface);

  &__item {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    padding: 0.5rem 0;
    color: var(--mat-sys-on-surface-variant);
    text-decoration: none;
    font-size: 0.75rem;

    &.active {
      color: var(--mat-sys-primary);
    }
  }
}
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/shell.spec.ts' Expected: PASS (1 test).

  • Step 5: Write the failing test for NotFound

Create src/app/core/layout/not-found/not-found.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NotFound } from './not-found';

describe('NotFound', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [NotFound],
      providers: [provideRouter([])],
    }).compileComponents();
  });

  it('renders a not-found message', () => {
    const fixture = TestBed.createComponent(NotFound);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Seite nicht gefunden');
  });
});
  • Step 6: Run the test to verify it fails

Run: npm test -- --include '**/not-found.spec.ts' Expected: FAIL — not-found.ts does not exist yet.

  • Step 7: Implement NotFound

Create src/app/core/layout/not-found/not-found.ts:

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-not-found',
  imports: [RouterLink],
  template: `
    <div class="not-found">
      <h1>Seite nicht gefunden</h1>
      <a routerLink="/">Zurück zur Startseite</a>
    </div>
  `,
})
export class NotFound {}
  • Step 8: Run the test to verify it passes

Run: npm test -- --include '**/not-found.spec.ts' Expected: PASS (1 test).

  • Step 9: Commit
git add src/app/core/layout
git commit -m "feat: add app shell with bottom navigation and not-found page"

Task 11: Stub Feature Screens (Overview, Members, Cashbox, More, Team-Select)

Files:

  • Create: src/app/features/team/overview/overview.ts + overview.spec.ts
  • Create: src/app/features/team/members/members.ts + members.spec.ts
  • Create: src/app/features/team/cashbox/cashbox.ts + cashbox.spec.ts
  • Create: src/app/features/team/more/more.ts + more.spec.ts
  • Create: src/app/features/team-select/team-select.ts + team-select.spec.ts

Interfaces:

  • Produces: five minimal standalone components, each rendering a single <h1> heading. These are intentional placeholders that later plans (Team-Select & Team Store, Overview, Members, Cashbox, More) will replace with real functionality — this task only proves the shell/routing wiring works end-to-end.

  • Step 1: Write the failing tests for all five stubs

Create src/app/features/team/overview/overview.spec.ts:

import { TestBed } from '@angular/core/testing';
import { Overview } from './overview';

describe('Overview', () => {
  it('renders the overview heading', async () => {
    await TestBed.configureTestingModule({ imports: [Overview] }).compileComponents();
    const fixture = TestBed.createComponent(Overview);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Übersicht');
  });
});

Create src/app/features/team/members/members.spec.ts:

import { TestBed } from '@angular/core/testing';
import { Members } from './members';

describe('Members', () => {
  it('renders the members heading', async () => {
    await TestBed.configureTestingModule({ imports: [Members] }).compileComponents();
    const fixture = TestBed.createComponent(Members);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Mitglieder');
  });
});

Create src/app/features/team/cashbox/cashbox.spec.ts:

import { TestBed } from '@angular/core/testing';
import { Cashbox } from './cashbox';

describe('Cashbox', () => {
  it('renders the cashbox heading', async () => {
    await TestBed.configureTestingModule({ imports: [Cashbox] }).compileComponents();
    const fixture = TestBed.createComponent(Cashbox);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Kasse');
  });
});

Create src/app/features/team/more/more.spec.ts:

import { TestBed } from '@angular/core/testing';
import { More } from './more';

describe('More', () => {
  it('renders the more heading', async () => {
    await TestBed.configureTestingModule({ imports: [More] }).compileComponents();
    const fixture = TestBed.createComponent(More);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Mehr');
  });
});

Create src/app/features/team-select/team-select.spec.ts:

import { TestBed } from '@angular/core/testing';
import { TeamSelect } from './team-select';

describe('TeamSelect', () => {
  it('renders the team-select heading', async () => {
    await TestBed.configureTestingModule({ imports: [TeamSelect] }).compileComponents();
    const fixture = TestBed.createComponent(TeamSelect);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Team auswählen');
  });
});
  • Step 2: Run the tests to verify they fail

Run: npm test -- --include '**/overview.spec.ts' --include '**/members.spec.ts' --include '**/cashbox.spec.ts' --include '**/more.spec.ts' --include '**/team-select.spec.ts' Expected: FAIL — none of the five component files exist yet.

  • Step 3: Implement the five stub components

Create src/app/features/team/overview/overview.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-overview',
  template: `<h1>Übersicht</h1>`,
})
export class Overview {}

Create src/app/features/team/members/members.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-members',
  template: `<h1>Mitglieder</h1>`,
})
export class Members {}

Create src/app/features/team/cashbox/cashbox.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-cashbox',
  template: `<h1>Kasse</h1>`,
})
export class Cashbox {}

Create src/app/features/team/more/more.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-more',
  template: `<h1>Mehr</h1>`,
})
export class More {}

Create src/app/features/team-select/team-select.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-team-select',
  template: `<h1>Team auswählen</h1>`,
})
export class TeamSelect {}
  • Step 4: Run the tests to verify they pass

Run: npm test -- --include '**/overview.spec.ts' --include '**/members.spec.ts' --include '**/cashbox.spec.ts' --include '**/more.spec.ts' --include '**/team-select.spec.ts' Expected: PASS (5 tests).

  • Step 5: Commit
git add src/app/features/team src/app/features/team-select
git commit -m "feat: add stub screens for overview, members, cashbox, more, team-select"

Task 12: Root Routing Wiring

Files:

  • Modify: src/app/app.routes.ts
  • Create: src/app/app.routes.spec.ts
  • Modify: src/app/app.ts
  • Modify: src/app/app.html
  • Modify: src/app/app.spec.ts

Interfaces:

  • Consumes: every component/guard produced in Tasks 811.

  • Produces: the final routes: Routes array; App trimmed to a bare <router-outlet> shell (the Angular welcome-page placeholder markup is removed).

  • Step 1: Write the failing routing integration test

Create src/app/app.routes.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import { AuthStore } from './core/auth/auth-store';

describe('app routing', () => {
  let router: Router;
  let authStore: AuthStore;

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({
      providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting(), provideAnimationsAsync()],
    });
    router = TestBed.inject(Router);
    authStore = TestBed.inject(AuthStore);
  });

  it('redirects the root path to login when logged out', async () => {
    await router.navigateByUrl('/');
    expect(router.url).toBe('/auth/login');
  });

  it('redirects a protected team route to login when logged out', async () => {
    await router.navigateByUrl('/team/1/overview');
    expect(router.url).toBe('/auth/login');
  });

  it('redirects the root path to team-select when logged in', async () => {
    authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
    await router.navigateByUrl('/');
    expect(router.url).toBe('/team-select');
  });

  it('redirects the bare team route to its overview child', async () => {
    authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
    await router.navigateByUrl('/team/1');
    expect(router.url).toBe('/team/1/overview');
  });

  it('falls back to the not-found route for unknown paths', async () => {
    await router.navigateByUrl('/does-not-exist');
    expect(router.url).toBe('/does-not-exist');
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- --include '**/app.routes.spec.ts' Expected: FAIL — app.routes.ts still exports an empty array.

  • Step 3: Implement the routes

Replace the contents of src/app/app.routes.ts:

import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth-guard';
import { rootRedirectGuard } from './core/auth/root-redirect-guard';
import { Shell } from './core/layout/shell/shell';

export const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    canActivate: [rootRedirectGuard],
    children: [],
  },
  {
    path: 'auth/login',
    loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
  },
  {
    path: 'team-select',
    canActivate: [authGuard],
    loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
  },
  {
    path: 'team/:id',
    canActivate: [authGuard],
    component: Shell,
    children: [
      { path: '', pathMatch: 'full', redirectTo: 'overview' },
      {
        path: 'overview',
        loadComponent: () => import('./features/team/overview/overview').then((m) => m.Overview),
      },
      {
        path: 'members',
        loadComponent: () => import('./features/team/members/members').then((m) => m.Members),
      },
      {
        path: 'cashbox',
        loadComponent: () => import('./features/team/cashbox/cashbox').then((m) => m.Cashbox),
      },
      {
        path: 'more',
        loadComponent: () => import('./features/team/more/more').then((m) => m.More),
      },
    ],
  },
  {
    path: '**',
    loadComponent: () => import('./core/layout/not-found/not-found').then((m) => m.NotFound),
  },
];
  • Step 4: Run the test to verify it passes

Run: npm test -- --include '**/app.routes.spec.ts' Expected: PASS (5 tests).

  • Step 5: Trim the root App component down to the router outlet

Replace src/app/app.html entirely with:

<router-outlet />

Replace src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  templateUrl: './app.html',
  styleUrl: './app.scss',
})
export class App {}

Replace src/app/app.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';

describe('App', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [App],
      providers: [provideRouter([])],
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(App);
    expect(fixture.componentInstance).toBeTruthy();
  });
});
  • Step 6: Run the full test suite

Run: npm test -- --include '**/app.spec.ts' --include '**/app.routes.spec.ts' Expected: PASS.

  • Step 7: Commit
git add src/app/app.routes.ts src/app/app.routes.spec.ts src/app/app.ts src/app/app.html src/app/app.spec.ts
git commit -m "feat: wire root routing (auth, team-select, team shell, not-found)"

Task 13: Final Verification

Files: none (verification only)

  • Step 1: Run the full unit test suite

Run: npm test Expected: all tests pass, no failures.

  • Step 2: Run a production build

Run: npm run build Expected: build succeeds; dist/myteamwallet_frontend_modern/browser/ contains index.html, ngsw.json, and hashed JS/CSS bundles.

  • Step 3: Check formatting

Run: npx prettier --check "src/**/*.{ts,html,scss}" Expected: no formatting issues. If issues are reported, run npx prettier --write "src/**/*.{ts,html,scss}" and re-check.

  • Step 4: Manual smoke check (requires the backend running on localhost:3999)

Run: npm start, open http://localhost:4200. Expected: redirected to /auth/login; a valid backend login redirects to /team-select showing "Team auswählen"; manually navigating to /team/1/overview shows the app shell with header + bottom nav, and the four tabs switch between the stub screens.

  • Step 5: Commit (only if Steps 13 required fixes)
git add -A
git commit -m "chore: fix formatting/build issues found during final verification"