Files
teamwallet/myteamwallet_frontend_modern/docs/superpowers/plans/2026-07-31-team-select-and-team-store.md
Bastian Wagner 6bea4f766a first commit
2026-07-31 21:02:47 +02:00

33 KiB
Raw Blame History

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 <name>/<name>.ts with PascalCase class and no Component suffix; services as <name>.ts with no Service suffix; stores follow the existing AuthStore precedent — <name>-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 '<glob>'.
  • Backend base URL pattern: {environment.apiUrl}<controller>/<path>. Relevant endpoints for this plan (verified against the actual NestJS backend source): GET {apiUrl}users/:id/teamsPlayer[] (each with an eager team relation), GET {apiUrl}teams/:id/overviewTeam (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:

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:

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
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<Player[]>GET {apiUrl}users/{userId}/teams, loadTeamOverview(teamId: number): Observable<Team>GET {apiUrl}teams/{teamId}/overview.

  • Step 1: Write the failing test

Create src/app/core/team/teams-api.spec.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:

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<Player[]> {
    return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
  }

  loadTeamOverview(teamId: number): Observable<Team> {
    return this.http.get<Team>(`${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
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<Player[]>, readonly loading: Signal<boolean>, 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:

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:

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<Player[]>([]);
  private readonly loadingSignal = signal(false);
  private readonly loadedForUserId = signal<number | null>(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
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<Team | null>, readonly loading: Signal<boolean>, 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:

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:

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<Team | null>(null);
  private readonly loadingSignal = signal(false);
  private readonly loadedTeamId = signal<number | null>(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
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: '<h1>Team auswählen</h1>' }) 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 <h1> 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:

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:

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:

@if (loading()) {
  <div class="team-select-loading">
    <mat-spinner diameter="32" />
  </div>
} @else if (players().length === 0) {
  <div class="team-select-empty">
    <p>Du bist noch keinem Team zugeordnet.</p>
  </div>
} @else {
  <div class="team-select-page">
    <h1>Team auswählen</h1>
    <mat-nav-list>
      @for (player of players(); track player.id) {
        <a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
          <span matListItemTitle>{{ player.team?.name }}</span>
          <span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
        </a>
      }
    </mat-nav-list>
  </div>
}

Create src/app/features/team-select/team-select.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
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:

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:

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<number>();
    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:

<mat-toolbar class="shell-header">
  @if (myTeams().length > 1) {
    <button mat-button [matMenuTriggerFor]="teamMenu" class="shell-team-switcher">
      <span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
      <mat-icon>arrow_drop_down</mat-icon>
    </button>
    <mat-menu #teamMenu="matMenu">
      @for (team of myTeams(); track team.id) {
        <button mat-menu-item (click)="switchTeam(team.id)">{{ team.name }}</button>
      }
    </mat-menu>
  } @else {
    <span>{{ currentTeam()?.name ?? '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>

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:

.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
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 13 required fixes)
git add -A
git commit -m "chore: fix formatting/build issues found during final verification"