# Team-Select & Team Store 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:** Replace the `TeamSelect` stub with a real screen that loads the logged-in user's team/player memberships from the backend, auto-continues straight to the team when there's only one, and add a shared `TeamStore`/`MyTeamsStore` plus a team-switcher menu in the `Shell` header so every `/team/:id/*` screen has the active team's data available. **Architecture:** Two new signal-based, `providedIn: 'root'` stores under `src/app/core/team/`: `MyTeamsStore` (the user's team/player list, consumed by both `TeamSelect` and `Shell`'s switcher) and `TeamStore` (the currently active team's full detail, loaded by `Shell` whenever the route's `:id` param changes, consumed by this and future plans' `Overview`/`Members`/`Cashbox`/`More` screens). Both stores load through a single `TeamsApi` service. No resolvers — loading is signal/effect-driven, consistent with the Foundation plan's architecture decision. **Tech Stack:** Angular 21 standalone components, Signals (`signal`/`computed`/`effect`), `@angular/core/rxjs-interop` (`takeUntilDestroyed`) for the route-param subscription, Angular Material (`MatListModule`, `MatMenuModule`, `MatButtonModule`, `MatProgressSpinnerModule`), Vitest. ## Global Constraints - Angular 21, standalone components only — no NgModules, no `standalone: true` flag. - File/class naming (confirmed via `ng generate` dry-runs in the Foundation plan): components in `/.ts` with `PascalCase` class and no `Component` suffix; services as `.ts` with no `Service` suffix; stores follow the existing `AuthStore` precedent — `-store.ts` file, `PascalCase` class ending in `Store`. - State management is native Signals in injectable services — no NgRx. - No i18n — German text hardcoded directly in templates. - This app is zoneless (no `zone.js`) — tests use `async`/`await` and `await fixture.whenStable()` to let signal effects settle, never `fakeAsync`/`tick`. - Tests use Vitest globals (`describe`/`it`/`expect`/`vi`), no imports needed. Single-spec-file runs: `npm test -- --include ''`. - Backend base URL pattern: `{environment.apiUrl}/`. Relevant endpoints for this plan (verified against the actual NestJS backend source): `GET {apiUrl}users/:id/teams` → `Player[]` (each with an eager `team` relation), `GET {apiUrl}teams/:id/overview` → `Team` (with `players` relation and a computed `outstanding` field). - `Player.teamRole` from the backend is an object (`{ id: number; name?: string }`), NOT the Foundation plan's `TeamRole` numeric enum — the two are different things. This plan does not wire permission checks (`canBook`/`canInvite`); that's for the Cashbox/Members plans once real booking actions exist. --- ## Task 1: Team & Player Models **Files:** - Create: `src/app/models/team.model.ts` - Create: `src/app/models/player.model.ts` **Interfaces:** - Produces: `interface Team { id: number; name: string; alias: string; balance: number; outstanding?: number; players?: Player[] }`, `interface PlayerTeamRole { id: number; name?: string }`, `interface Player { id: number; firstName: string; lastName: string; teamRole?: PlayerTeamRole | null; team?: Team; balance: number; active: boolean; user?: User | null }`. No dedicated test file — these are pure data-shape interfaces with no logic, consistent with how `role.model.ts`/`status.model.ts`/`user.model.ts` were handled in the Foundation plan (only files with actual logic, like `team-role.model.ts`'s permission helpers, got tests). - [ ] **Step 1: Create the Team model** Create `src/app/models/team.model.ts`: ```ts import { Player } from './player.model'; export interface Team { id: number; name: string; alias: string; balance: number; outstanding?: number; players?: Player[]; } ``` - [ ] **Step 2: Create the Player model** Create `src/app/models/player.model.ts`: ```ts import { Team } from './team.model'; import { User } from './user.model'; export interface PlayerTeamRole { id: number; name?: string; } export interface Player { id: number; firstName: string; lastName: string; teamRole?: PlayerTeamRole | null; team?: Team; balance: number; active: boolean; user?: User | null; } ``` `team.model.ts` and `player.model.ts` import from each other — this is safe for TypeScript `interface`-only imports (interfaces are erased at compile time, so there's no runtime circular dependency). - [ ] **Step 3: Verify the project still compiles** Run: `npm run build -- --configuration development` Expected: no compile errors (both new files are unused so far, but must be valid TypeScript). - [ ] **Step 4: Commit** ```bash git add src/app/models/team.model.ts src/app/models/player.model.ts git commit -m "feat: add Team and Player domain models" ``` --- ## Task 2: TeamsApi **Files:** - Create: `src/app/core/team/teams-api.ts` - Test: `src/app/core/team/teams-api.spec.ts` **Interfaces:** - Consumes: `environment` from `../../../environments/environment`, `Player` from `../../models/player.model`, `Team` from `../../models/team.model`. - Produces: `class TeamsApi` (`providedIn: 'root'`) with `loadMyTeams(userId: number): Observable` → `GET {apiUrl}users/{userId}/teams`, `loadTeamOverview(teamId: number): Observable` → `GET {apiUrl}teams/{teamId}/overview`. - [ ] **Step 1: Write the failing test** Create `src/app/core/team/teams-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 { TeamsApi } from './teams-api'; import { environment } from '../../../environments/environment'; import { Player } from '../../models/player.model'; import { Team } from '../../models/team.model'; describe('TeamsApi', () => { let service: TeamsApi; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()], }); service = TestBed.inject(TeamsApi); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('fetches the players/teams belonging to a user', () => { const players: Player[] = [ { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }, ]; service.loadMyTeams(42).subscribe((response) => { expect(response).toEqual(players); }); const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`); expect(request.request.method).toBe('GET'); request.flush(players); }); it('fetches a team overview by id', () => { const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 0 }; service.loadTeamOverview(5).subscribe((response) => { expect(response).toEqual(team); }); const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`); expect(request.request.method).toBe('GET'); request.flush(team); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npm test -- --include '**/teams-api.spec.ts'` Expected: FAIL — `teams-api.ts` does not exist yet. - [ ] **Step 3: Implement TeamsApi** Create `src/app/core/team/teams-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 { Player } from '../../models/player.model'; import { Team } from '../../models/team.model'; @Injectable({ providedIn: 'root' }) export class TeamsApi { private readonly http = inject(HttpClient); loadMyTeams(userId: number): Observable { return this.http.get(`${environment.apiUrl}users/${userId}/teams`); } loadTeamOverview(teamId: number): Observable { return this.http.get(`${environment.apiUrl}teams/${teamId}/overview`); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test -- --include '**/teams-api.spec.ts'` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/app/core/team/teams-api.ts src/app/core/team/teams-api.spec.ts git commit -m "feat: add TeamsApi for loading a user's teams and a team overview" ``` --- ## Task 3: MyTeamsStore **Files:** - Create: `src/app/core/team/my-teams-store.ts` - Test: `src/app/core/team/my-teams-store.spec.ts` **Interfaces:** - Consumes: `TeamsApi` from `./teams-api`, `Player` from `../../models/player.model`. - Produces: `class MyTeamsStore` (`providedIn: 'root'`) with `readonly players: Signal`, `readonly loading: Signal`, `ensureLoaded(userId: number): void` (no-op if already loaded for that user, or already loading). - [ ] **Step 1: Write the failing test** Create `src/app/core/team/my-teams-store.spec.ts`: ```ts import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { MyTeamsStore } from './my-teams-store'; import { environment } from '../../../environments/environment'; import { Player } from '../../models/player.model'; describe('MyTeamsStore', () => { let store: MyTeamsStore; let httpMock: HttpTestingController; const player: Player = { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 }, }; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()], }); store = TestBed.inject(MyTeamsStore); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('loads and exposes the players for a user', () => { store.ensureLoaded(42); expect(store.loading()).toBe(true); const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`); request.flush([player]); expect(store.loading()).toBe(false); expect(store.players()).toEqual([player]); }); it('does not reload for the same user id', () => { store.ensureLoaded(42); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]); store.ensureLoaded(42); httpMock.expectNone(`${environment.apiUrl}users/42/teams`); }); it('resets loading on error without throwing', () => { store.ensureLoaded(42); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush('error', { status: 500, statusText: 'Server Error', }); expect(store.loading()).toBe(false); expect(store.players()).toEqual([]); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npm test -- --include '**/my-teams-store.spec.ts'` Expected: FAIL — `my-teams-store.ts` does not exist yet. - [ ] **Step 3: Implement MyTeamsStore** Create `src/app/core/team/my-teams-store.ts`: ```ts import { Injectable, inject, signal } from '@angular/core'; import { Player } from '../../models/player.model'; import { TeamsApi } from './teams-api'; @Injectable({ providedIn: 'root' }) export class MyTeamsStore { private readonly teamsApi = inject(TeamsApi); private readonly playersSignal = signal([]); private readonly loadingSignal = signal(false); private readonly loadedForUserId = signal(null); readonly players = this.playersSignal.asReadonly(); readonly loading = this.loadingSignal.asReadonly(); ensureLoaded(userId: number): void { if (this.loadedForUserId() === userId || this.loadingSignal()) { return; } this.loadingSignal.set(true); this.teamsApi.loadMyTeams(userId).subscribe({ next: (players) => { this.playersSignal.set(players); this.loadedForUserId.set(userId); this.loadingSignal.set(false); }, error: () => { this.loadingSignal.set(false); }, }); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test -- --include '**/my-teams-store.spec.ts'` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add src/app/core/team/my-teams-store.ts src/app/core/team/my-teams-store.spec.ts git commit -m "feat: add MyTeamsStore for the user's team memberships" ``` --- ## Task 4: TeamStore **Files:** - Create: `src/app/core/team/team-store.ts` - Test: `src/app/core/team/team-store.spec.ts` **Interfaces:** - Consumes: `TeamsApi` from `./teams-api`, `Team` from `../../models/team.model`. - Produces: `class TeamStore` (`providedIn: 'root'`) with `readonly team: Signal`, `readonly loading: Signal`, `loadTeam(teamId: number): void` (no-op if already loaded for that team id). - [ ] **Step 1: Write the failing test** Create `src/app/core/team/team-store.spec.ts`: ```ts import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TeamStore } from './team-store'; import { environment } from '../../../environments/environment'; import { Team } from '../../models/team.model'; describe('TeamStore', () => { let store: TeamStore; let httpMock: HttpTestingController; const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 100 }; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()], }); store = TestBed.inject(TeamStore); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('loads and exposes the active team', () => { store.loadTeam(5); expect(store.loading()).toBe(true); const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`); request.flush(team); expect(store.loading()).toBe(false); expect(store.team()).toEqual(team); }); it('does not reload for the same team id', () => { store.loadTeam(5); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team); store.loadTeam(5); httpMock.expectNone(`${environment.apiUrl}teams/5/overview`); }); it('reloads when the team id changes', () => { store.loadTeam(5); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team); store.loadTeam(6); httpMock.expectOne(`${environment.apiUrl}teams/6/overview`).flush({ ...team, id: 6 }); expect(store.team()?.id).toBe(6); }); it('resets loading on error without throwing', () => { store.loadTeam(5); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush('error', { status: 500, statusText: 'Server Error', }); expect(store.loading()).toBe(false); expect(store.team()).toBeNull(); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npm test -- --include '**/team-store.spec.ts'` Expected: FAIL — `team-store.ts` does not exist yet. - [ ] **Step 3: Implement TeamStore** Create `src/app/core/team/team-store.ts`: ```ts import { Injectable, inject, signal } from '@angular/core'; import { Team } from '../../models/team.model'; import { TeamsApi } from './teams-api'; @Injectable({ providedIn: 'root' }) export class TeamStore { private readonly teamsApi = inject(TeamsApi); private readonly teamSignal = signal(null); private readonly loadingSignal = signal(false); private readonly loadedTeamId = signal(null); readonly team = this.teamSignal.asReadonly(); readonly loading = this.loadingSignal.asReadonly(); loadTeam(teamId: number): void { if (this.loadedTeamId() === teamId) { return; } this.loadingSignal.set(true); this.teamsApi.loadTeamOverview(teamId).subscribe({ next: (team) => { this.teamSignal.set(team); this.loadedTeamId.set(teamId); this.loadingSignal.set(false); }, error: () => { this.loadingSignal.set(false); }, }); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test -- --include '**/team-store.spec.ts'` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/app/core/team/team-store.ts src/app/core/team/team-store.spec.ts git commit -m "feat: add TeamStore for the active team's detail" ``` --- ## Task 5: Real Team-Select Screen **Files:** - Modify: `src/app/features/team-select/team-select.ts` (currently a stub: `@Component({ selector: 'app-team-select', template: '

Team auswählen

' }) export class TeamSelect {}`) - Create: `src/app/features/team-select/team-select.html` - Create: `src/app/features/team-select/team-select.scss` - Modify: `src/app/features/team-select/team-select.spec.ts` (currently a single stub test asserting the `

` text) **Interfaces:** - Consumes: `AuthStore` from `../../core/auth/auth-store` (`.currentUser()`), `MyTeamsStore` from `../../core/team/my-teams-store` (`.players()`, `.loading()`, `.ensureLoaded(userId)`). - Produces: `class TeamSelect` — renders a loading state, an empty state, or a list of team entries; auto-navigates to `/team/:id/overview` when the user belongs to exactly one team. - [ ] **Step 1: Write the failing tests** Replace `src/app/features/team-select/team-select.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 { TeamSelect } from './team-select'; import { environment } from '../../../environments/environment'; import { AuthStore } from '../../core/auth/auth-store'; import { Player } from '../../models/player.model'; describe('TeamSelect', () => { let httpMock: HttpTestingController; let router: Router; let authStore: AuthStore; beforeEach(async () => { localStorage.clear(); await TestBed.configureTestingModule({ imports: [TeamSelect], providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])], }).compileComponents(); httpMock = TestBed.inject(HttpTestingController); router = TestBed.inject(Router); authStore = TestBed.inject(AuthStore); authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' }); }); afterEach(() => { httpMock.verify(); }); it('renders one entry per team when the user has several', async () => { const players: Player[] = [ { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 5, name: 'Team A', alias: 'a', balance: 0 }, }, { id: 2, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 6, name: 'Team B', alias: 'b', balance: 0 }, }, ]; const fixture = TestBed.createComponent(TeamSelect); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players); await fixture.whenStable(); fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll('a[mat-list-item]'); expect(items.length).toBe(2); }); it('redirects automatically when the user has exactly one team', async () => { const players: Player[] = [ { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 5, name: 'Team A', alias: 'a', balance: 0 }, }, ]; const navigateSpy = vi.spyOn(router, 'navigate'); const fixture = TestBed.createComponent(TeamSelect); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players); await fixture.whenStable(); expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'overview']); }); it('shows an empty state when the user has no teams', async () => { const fixture = TestBed.createComponent(TeamSelect); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Du bist noch keinem Team zugeordnet.'); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `npm test -- --include '**/team-select.spec.ts'` Expected: FAIL — the stub component doesn't inject `AuthStore`/`MyTeamsStore` or render a list, so none of the 3 new assertions pass. - [ ] **Step 3: Implement the real TeamSelect component** Replace `src/app/features/team-select/team-select.ts`: ```ts import { Component, effect, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { Router } from '@angular/router'; import { MatListModule } from '@angular/material/list'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AuthStore } from '../../core/auth/auth-store'; import { MyTeamsStore } from '../../core/team/my-teams-store'; @Component({ selector: 'app-team-select', imports: [RouterLink, MatListModule, MatProgressSpinnerModule], templateUrl: './team-select.html', styleUrl: './team-select.scss', }) export class TeamSelect { private readonly authStore = inject(AuthStore); private readonly myTeamsStore = inject(MyTeamsStore); private readonly router = inject(Router); protected readonly players = this.myTeamsStore.players; protected readonly loading = this.myTeamsStore.loading; constructor() { const userId = this.authStore.currentUser()?.id; if (userId) { this.myTeamsStore.ensureLoaded(userId); } effect(() => { const players = this.myTeamsStore.players(); if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) { void this.router.navigate(['/team', players[0].team.id, 'overview']); } }); } } ``` Create `src/app/features/team-select/team-select.html`: ```html @if (loading()) {
} @else if (players().length === 0) {

Du bist noch keinem Team zugeordnet.

} @else {

Team auswählen

@for (player of players(); track player.id) { {{ player.team?.name }} {{ player.firstName }} {{ player.lastName }} }
} ``` Create `src/app/features/team-select/team-select.scss`: ```scss .team-select-loading, .team-select-empty { display: flex; justify-content: center; align-items: center; min-height: 60dvh; padding: 1rem; text-align: center; } .team-select-page { padding: 1rem; } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `npm test -- --include '**/team-select.spec.ts'` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add src/app/features/team-select git commit -m "feat: implement real team-select screen with auto-redirect for a single team" ``` --- ## Task 6: Team Switcher in the App Shell **Files:** - Modify: `src/app/core/layout/shell/shell.ts` - Modify: `src/app/core/layout/shell/shell.html` - Modify: `src/app/core/layout/shell/shell.scss` - Modify: `src/app/core/layout/shell/shell.spec.ts` **Interfaces:** - Consumes: `AuthStore` from `../../auth/auth-store`, `MyTeamsStore`/`TeamStore` from `../../team/my-teams-store` and `../../team/team-store`, `Team` from `../../../models/team.model`. - Produces: `Shell` now loads the active team (via the route's `:id` param) into `TeamStore` on every navigation into `/team/:id/*`, and shows a team-switcher menu in the header when the user belongs to more than one team. - [ ] **Step 1: Write the failing tests** Replace `src/app/core/layout/shell/shell.spec.ts`: ```ts import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { Shell } from './shell'; import { environment } from '../../../../environments/environment'; import { AuthStore } from '../../auth/auth-store'; import { Player } from '../../../models/player.model'; describe('Shell', () => { let httpMock: HttpTestingController; let authStore: AuthStore; beforeEach(async () => { localStorage.clear(); await TestBed.configureTestingModule({ imports: [Shell], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([]), { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: '5' })) }, }, ], }).compileComponents(); httpMock = TestBed.inject(HttpTestingController); authStore = TestBed.inject(AuthStore); authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' }); }); afterEach(() => { httpMock.verify(); }); it('renders the bottom navigation with four tabs', () => { const fixture = TestBed.createComponent(Shell); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0, }); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]); const links = fixture.nativeElement.querySelectorAll('.shell-bottom-nav__item'); expect(links.length).toBe(4); }); it('loads the team for the route id and shows its name in the header', async () => { const fixture = TestBed.createComponent(Shell); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0, }); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.shell-header')?.textContent).toContain('Team A'); }); it('shows a team switcher when the user belongs to more than one team', async () => { const players: Player[] = [ { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 5, name: 'Team A', alias: 'a', balance: 0 }, }, { id: 2, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 6, name: 'Team B', alias: 'b', balance: 0 }, }, ]; const fixture = TestBed.createComponent(Shell); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0, }); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeTruthy(); }); it('does not show a team switcher when the user belongs to only one team', async () => { const players: Player[] = [ { id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true, team: { id: 5, name: 'Team A', alias: 'a', balance: 0 }, }, ]; const fixture = TestBed.createComponent(Shell); fixture.detectChanges(); httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0, }); httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeFalsy(); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `npm test -- --include '**/shell.spec.ts'` Expected: FAIL — the current `Shell` doesn't inject `ActivatedRoute`/`TeamStore`/`MyTeamsStore`, doesn't make the two HTTP calls the tests expect, and has no `.shell-team-switcher` element. - [ ] **Step 3: Implement the team switcher** Replace `src/app/core/layout/shell/shell.ts`: ```ts import { Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatToolbarModule } from '@angular/material/toolbar'; import { AuthStore } from '../../auth/auth-store'; import { MyTeamsStore } from '../../team/my-teams-store'; import { TeamStore } from '../../team/team-store'; import { Team } from '../../../models/team.model'; @Component({ selector: 'app-shell', imports: [ RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatIconModule, MatMenuModule, MatButtonModule, ], templateUrl: './shell.html', styleUrl: './shell.scss', }) export class Shell { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly authStore = inject(AuthStore); private readonly myTeamsStore = inject(MyTeamsStore); private readonly teamStore = inject(TeamStore); protected readonly currentTeam = this.teamStore.team; protected readonly myTeams = computed(() => { const seen = new Set(); const teams: Team[] = []; for (const player of this.myTeamsStore.players()) { if (player.team && !seen.has(player.team.id)) { seen.add(player.team.id); teams.push(player.team); } } return teams; }); constructor() { const userId = this.authStore.currentUser()?.id; if (userId) { this.myTeamsStore.ensureLoaded(userId); } // A direct subscription (not `effect()` + `toSignal()`) so the initial // team load happens synchronously during construction, exactly like // `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its // current value synchronously to a new subscriber. This keeps the // component's behavior deterministic and trivial to test: no signal // effect scheduling to wait for. this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { const id = Number(params.get('id')); if (!Number.isNaN(id)) { this.teamStore.loadTeam(id); } }); } protected switchTeam(teamId: number): void { void this.router.navigate(['/team', teamId, 'overview']); } } ``` Replace `src/app/core/layout/shell/shell.html`: ```html @if (myTeams().length > 1) { @for (team of myTeams(); track team.id) { } } @else { {{ currentTeam()?.name ?? 'TeamWallet' }} }
``` Edit `src/app/core/layout/shell/shell.scss` — add a rule for the new button so it matches the existing header's color instead of the Material default button colors: ```scss .shell-team-switcher { color: var(--mat-sys-on-primary); } ``` Add this rule right after the existing `.shell-header` rule (keep everything else in the file unchanged). - [ ] **Step 4: Run the tests to verify they pass** Run: `npm test -- --include '**/shell.spec.ts'` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/app/core/layout/shell git commit -m "feat: load the active team in Shell and add a team switcher menu" ``` --- ## Task 7: Final Verification **Files:** none (verification only) - [ ] **Step 1: Run the full unit test suite** Run: `npm test` Expected: all tests pass, no failures (Foundation plan's 36 tests plus this plan's new ones). - [ ] **Step 2: Run a production build** Run: `npm run build` Expected: build succeeds with no errors or new warnings. - [ ] **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: Commit (only if Steps 1–3 required fixes)** ```bash git add -A git commit -m "chore: fix formatting/build issues found during final verification" ```