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

1854 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`:
```bash
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:
```scss
@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**
```bash
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**
```bash
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:
```json
{
"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**
```bash
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`:
```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`:
```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:
```json
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
```
Replace with:
```json
"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**
```bash
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`:
```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`:
```ts
export interface Role {
id: number;
name?: string;
}
export enum RoleId {
Admin = 1,
User = 2,
}
```
Create `src/app/models/status.model.ts`:
```ts
export interface Status {
id: number;
name?: string;
}
```
Create `src/app/models/user.model.ts`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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:
```ts
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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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**
```bash
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 `routerLink`s). `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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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**
```bash
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`:
```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`:
```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:
```html
<router-outlet />
```
Replace `src/app/app.ts`:
```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`:
```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**
```bash
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)**
```bash
git add -A
git commit -m "chore: fix formatting/build issues found during final verification"
```