56 KiB
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: trueflag 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 athttp://localhost:3999/api/v1/. - Tests use Vitest via the
@angular/build:unit-testbuilder;describe/it/expect/viare globals (see existingsrc/app/app.spec.ts— no test-framework imports needed). - File/class naming follows this repo's existing scaffold convention (confirmed via
ng generatedry-runs): components live in their own folder as<name>/<name>.ts(classPascalCase, noComponentsuffix), services as<name>.ts(class noServicesuffix), guards as<name>-guard.ts(const<name>Guard), interceptors as<name>-interceptor.ts(const<name>Interceptor). - This app is zoneless (no
zone.jsdependency) — tests must useasync/awaitwith Promise-returning APIs (e.g.await router.navigateByUrl(...)), neverfakeAsync/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 usingmat.$green-palette(primary) andmat.$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 inapp.config.tsproviders (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'sdevelopmentconfiguration)
Interfaces:
-
Produces:
environment: { production: boolean; apiUrl: string }, importable asimport { 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): booleanfunction 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:
Userfrom../../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): voidclearSession(): void- persists to
localStorageunder keystw_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:
AuthStorefrom../auth/auth-store. -
Produces:
authInterceptor: HttpInterceptorFn,errorInterceptor: HttpInterceptorFn, both registered viaprovideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))inapp.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:
environmentfrom../../../environments/environment,Userfrom../../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/loginme(): 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:
AuthStorefrom./auth-store. -
Produces:
authGuard: CanActivateFn(blocks unauthenticated access, redirects to/auth/login),rootRedirectGuard: CanActivateFn(always redirects/to/team-selector/auth/loginbased 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:
AuthApifrom../../../core/auth/auth-api,AuthStorefrom../../../core/auth/auth-store. -
Produces:
class Login, selectorapp-login, standalone component with a reactive email/password form; on success callsauthStore.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(selectorapp-shell) rendering a header, a<router-outlet>for the active team route, and a 4-item bottom nav (overview/members/cashbox/more, relativerouterLinks).class NotFound(selectorapp-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 8–11.
-
Produces: the final
routes: Routesarray;Apptrimmed 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 1–3 required fixes)
git add -A
git commit -m "chore: fix formatting/build issues found during final verification"