33 KiB
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: trueflag. - File/class naming (confirmed via
ng generatedry-runs in the Foundation plan): components in<name>/<name>.tswithPascalCaseclass and noComponentsuffix; services as<name>.tswith noServicesuffix; stores follow the existingAuthStoreprecedent —<name>-store.tsfile,PascalCaseclass ending inStore. - 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 useasync/awaitandawait fixture.whenStable()to let signal effects settle, neverfakeAsync/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/teams→Player[](each with an eagerteamrelation),GET {apiUrl}teams/:id/overview→Team(withplayersrelation and a computedoutstandingfield). Player.teamRolefrom the backend is an object ({ id: number; name?: string }), NOT the Foundation plan'sTeamRolenumeric 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:
environmentfrom../../../environments/environment,Playerfrom../../models/player.model,Teamfrom../../models/team.model. -
Produces:
class TeamsApi(providedIn: 'root') withloadMyTeams(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:
TeamsApifrom./teams-api,Playerfrom../../models/player.model. -
Produces:
class MyTeamsStore(providedIn: 'root') withreadonly 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:
TeamsApifrom./teams-api,Teamfrom../../models/team.model. -
Produces:
class TeamStore(providedIn: 'root') withreadonly 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:
AuthStorefrom../../core/auth/auth-store(.currentUser()),MyTeamsStorefrom../../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/overviewwhen 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:
AuthStorefrom../../auth/auth-store,MyTeamsStore/TeamStorefrom../../team/my-teams-storeand../../team/team-store,Teamfrom../../../models/team.model. -
Produces:
Shellnow loads the active team (via the route's:idparam) intoTeamStoreon 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 1–3 required fixes)
git add -A
git commit -m "chore: fix formatting/build issues found during final verification"