From ecb6fd439484fc4b3171b330ae510dedc3c2fd2c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 19:44:49 +0200 Subject: [PATCH 1/5] feat(teams): add overview stats endpoint for KPI charts Adds GET :id/overview/stats + TeamsService#getOverviewStats, aggregating team-wallet and player payment transactions into a 12-month balanceHistory (cumulative, carry-forward), monthlyFlow (income/expense), and topOutstanding (top 10 active debtors) for the upcoming overview KPI charts. fine/levy/fee and player-level credit are excluded, matching the "Ist-Kasse" cash-flow rule. Replaces the unmodified NestJS-boilerplate placeholder specs for TeamsService/TeamsController (which already failed at baseline) with real tests using the team-access.service.spec.ts direct-construction convention. Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.controller.spec.ts | 53 +++- .../src/teams/teams.controller.ts | 9 + .../src/teams/teams.service.spec.ts | 231 +++++++++++++++++- .../src/teams/teams.service.ts | 89 +++++++ 4 files changed, 364 insertions(+), 18 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.controller.spec.ts b/myteamwallet_backend/src/teams/teams.controller.spec.ts index 4cdb9f9..fb0f7e5 100644 --- a/myteamwallet_backend/src/teams/teams.controller.spec.ts +++ b/myteamwallet_backend/src/teams/teams.controller.spec.ts @@ -1,18 +1,53 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { RoleEnum } from '../roles/roles.enum'; import { TeamsController } from './teams.controller'; describe('TeamsController', () => { + const service = { + getOverview: jest.fn(), + getOverviewStats: jest.fn(), + getTeamTransactions: jest.fn(), + createNewPlayer: jest.fn(), + updatePlayer: jest.fn(), + createNewTeam: jest.fn(), + }; + const publicAccess = {}; + const teamMembers = {}; + let controller: TeamsController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [TeamsController], - }).compile(); - - controller = module.get(TeamsController); + beforeEach(() => { + jest.resetAllMocks(); + controller = new TeamsController( + service as any, + publicAccess as any, + teamMembers as any, + ); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + describe('GET :id/overview/stats', () => { + it('carries the same guards and roles as GET :id/overview', () => { + expect( + Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.getOverviewStats), + ).toEqual(Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.findOne)); + + expect( + Reflect.getMetadata('roles', TeamsController.prototype.getOverviewStats), + ).toEqual([RoleEnum.user, RoleEnum.admin]); + + expect( + Reflect.getMetadata('roles', TeamsController.prototype.getOverviewStats), + ).toEqual(Reflect.getMetadata('roles', TeamsController.prototype.findOne)); + }); + + it('delegates to service.getOverviewStats with the route id', () => { + const stats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; + service.getOverviewStats.mockReturnValue(stats); + + const result = controller.getOverviewStats('7'); + + expect(service.getOverviewStats).toHaveBeenCalledWith('7'); + expect(result).toBe(stats); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index d39a5e0..7ffdf10 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -86,6 +86,15 @@ export class TeamsController { return this.service.getOverview(id); } + @ApiBearerAuth() + @Roles([RoleEnum.user, RoleEnum.admin]) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Get(':id/overview/stats') + @HttpCode(HttpStatus.OK) + getOverviewStats(@Param('id') id: string) { + return this.service.getOverviewStats(id); + } + @ApiOperation({ summary: 'Transactionen für ein Team', description: diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 88b4987..b8ccb21 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -1,18 +1,231 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { TeamsService } from './teams.service'; describe('TeamsService', () => { + const repository = { findOneOrFail: jest.fn(), findOneBy: jest.fn() }; + const playerRepository = {}; + const transactionsRepository = {}; + const rolesRepository = {}; + const settingsRepository = {}; + const teamWalletTransactionRepository = {}; + const logger = { info: jest.fn(), debug: jest.fn() }; + let service: TeamsService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [TeamsService], - }).compile(); - - service = module.get(TeamsService); + beforeEach(() => { + jest.resetAllMocks(); + service = new TeamsService( + repository as any, + playerRepository as any, + transactionsRepository as any, + rolesRepository as any, + settingsRepository as any, + teamWalletTransactionRepository as any, + logger as any, + ); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('getOverviewStats', () => { + // "now" is fixed to 2026-08-15, so the 12-month window covers + // 2025-09 .. 2026-08. + function buildTeam() { + return { + id: 7, + balance: 190, + transactions: [ + // TeamWalletTransaction: credit adds, expense subtracts. + { + id: 1, + date: '2025-09-15T10:00:00.000Z', + amount: '100.00', + type: { name: 'credit' }, + }, + { + id: 2, + date: '2026-02-20T10:00:00.000Z', + amount: '40.00', + type: { name: 'expense' }, + }, + { + id: 3, + date: '2026-07-01T10:00:00.000Z', + amount: '60.00', + type: { name: 'credit' }, + }, + ], + players: [ + { + id: 101, + firstName: 'Anna', + lastName: 'Aktive', + active: true, + balance: -40, + transactions: [ + { + id: 11, + date: '2025-10-05T09:00:00.000Z', + amount: '50.00', + type: { name: 'payment' }, + }, + // fine: raised debt, not yet paid -> must be excluded entirely. + { + id: 12, + date: '2026-01-10T09:00:00.000Z', + amount: '30.00', + type: { name: 'fine' }, + }, + // player-level credit does not touch team.balance -> excluded. + { + id: 13, + date: '2026-05-01T09:00:00.000Z', + amount: '15.00', + type: { name: 'credit' }, + }, + ], + }, + { + id: 102, + firstName: 'Bea', + lastName: 'Berg', + active: true, + balance: -75, + transactions: [ + { + id: 21, + date: '2026-03-01T09:00:00.000Z', + amount: '20.00', + type: { name: 'payment' }, + }, + // levy: raised debt, not yet paid -> must be excluded entirely. + { + id: 22, + date: '2026-04-01T09:00:00.000Z', + amount: '10.00', + type: { name: 'levy' }, + }, + ], + }, + { + id: 103, + firstName: 'Carla', + lastName: 'Inaktiv', + active: false, + balance: -1000, + transactions: [], + }, + { + id: 104, + firstName: 'Dana', + lastName: 'Doe', + active: true, + balance: 0, + transactions: [], + }, + ], + }; + } + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-15T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('builds a 12-month balanceHistory with carry-forward and correct sign handling', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.balanceHistory).toEqual([ + { month: '2025-09', balance: 100 }, + { month: '2025-10', balance: 150 }, + { month: '2025-11', balance: 150 }, + { month: '2025-12', balance: 150 }, + { month: '2026-01', balance: 150 }, + { month: '2026-02', balance: 110 }, + { month: '2026-03', balance: 130 }, + { month: '2026-04', balance: 130 }, + { month: '2026-05', balance: 130 }, + { month: '2026-06', balance: 130 }, + { month: '2026-07', balance: 190 }, + { month: '2026-08', balance: 190 }, + ]); + }); + + it('sanity check: the last balanceHistory entry equals team.balance', async () => { + const team = buildTeam(); + repository.findOneOrFail.mockResolvedValue(team); + + const result = await service.getOverviewStats('7'); + + expect(result.balanceHistory.at(-1).balance).toBe(team.balance); + }); + + it('groups monthlyFlow into income (payment+credit) and expense, excluding fine/levy/fee', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.monthlyFlow).toEqual([ + { month: '2025-09', income: 100, expense: 0 }, + { month: '2025-10', income: 50, expense: 0 }, + { month: '2025-11', income: 0, expense: 0 }, + { month: '2025-12', income: 0, expense: 0 }, + { month: '2026-01', income: 0, expense: 0 }, + { month: '2026-02', income: 0, expense: 40 }, + { month: '2026-03', income: 20, expense: 0 }, + { month: '2026-04', income: 0, expense: 0 }, + { month: '2026-05', income: 0, expense: 0 }, + { month: '2026-06', income: 0, expense: 0 }, + { month: '2026-07', income: 60, expense: 0 }, + { month: '2026-08', income: 0, expense: 0 }, + ]); + }); + + it('limits topOutstanding to active players with negative balance, sorted by debt descending', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.topOutstanding).toEqual([ + { playerId: 102, playerName: 'Bea Berg', balance: 75 }, + { playerId: 101, playerName: 'Anna Aktive', balance: 40 }, + ]); + }); + + it('limits topOutstanding to at most 10 entries', async () => { + const team = buildTeam(); + team.players = Array.from({ length: 15 }, (_, i) => ({ + id: 200 + i, + firstName: 'Player', + lastName: `${i}`, + active: true, + balance: -(i + 1), + transactions: [], + })); + repository.findOneOrFail.mockResolvedValue(team); + + const result = await service.getOverviewStats('7'); + + expect(result.topOutstanding).toHaveLength(10); + // highest debt first + expect(result.topOutstanding[0]).toEqual({ + playerId: 214, + playerName: 'Player 14', + balance: 15, + }); + }); + + it('loads the team with the same relations used by getTeamTransactions', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + await service.getOverviewStats('7'); + + expect(repository.findOneOrFail).toHaveBeenCalledWith({ + where: { id: 7 }, + relations: ['players', 'players.transactions', 'transactions'], + }); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 3bac329..b883c7d 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -218,6 +218,95 @@ export class TeamsService { return result; } + async getOverviewStats(teamId: string | number): Promise<{ + balanceHistory: { month: string; balance: number }[]; + monthlyFlow: { month: string; income: number; expense: number }[]; + topOutstanding: { playerId: number; playerName: string; balance: number }[]; + }> { + const id = Number(teamId); + + const team = await this.repository.findOneOrFail({ + where: { id }, + relations: ['players', 'players.transactions', 'transactions'], + }); + + // Only payment/credit/expense movements represent actual cash flow and + // are the only types that touch team.balance (see setBalance() on + // TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's + // debt but never move money, so they are excluded entirely. + const movements: { date: string; amount: number; type: string }[] = []; + + for (const t of team.transactions) { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + + for (const p of team.players) { + for (const t of p.transactions) { + if (t.type.name === 'payment') { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + } + } + + movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + + const months = this.getLast12Months(); + + let cumulativeBalance = 0; + let movementIndex = 0; + const balanceHistory = months.map((month) => { + while ( + movementIndex < movements.length && + movements[movementIndex].date.slice(0, 7) <= month + ) { + cumulativeBalance += this.signedFlowAmount(movements[movementIndex]); + movementIndex++; + } + return { month, balance: this.round(cumulativeBalance) }; + }); + + const monthlyFlow = months.map((month) => { + const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month); + const income = monthMovements + .filter((m) => m.type === 'payment' || m.type === 'credit') + .reduce((sum, m) => sum + m.amount, 0); + const expense = monthMovements + .filter((m) => m.type === 'expense') + .reduce((sum, m) => sum + m.amount, 0); + return { month, income: this.round(income), expense: this.round(expense) }; + }); + + const topOutstanding = team.players + .filter((p) => p.active && Number(p.balance) < 0) + .sort((a, b) => Number(a.balance) - Number(b.balance)) + .slice(0, 10) + .map((p) => ({ + playerId: p.id, + playerName: p.firstName + ' ' + p.lastName, + balance: this.round(Math.abs(Number(p.balance))), + })); + + return { balanceHistory, monthlyFlow, topOutstanding }; + } + + private getLast12Months(): string[] { + const now = new Date(); + const months: string[] = []; + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); + } + return months; + } + + private signedFlowAmount(movement: { amount: number; type: string }): number { + return movement.type === 'expense' ? -movement.amount : movement.amount; + } + + private round(value: number): number { + return Math.round(value * 100) / 100; + } + async updatePlayer(playerDTO: UpdatePlayerProfileDto) { const player = await this.playerRepository.findOneOrFail({ where: { From 435b8d5c53760f095fc2910e948fca667402436b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 20:21:50 +0200 Subject: [PATCH 2/5] feat(overview): add KPI charts to team overview page Adds three chart.js-backed KPI cards (Kassenstand-Verlauf, Einnahmen & Ausgaben, Top-10 offene Beitraege) to the existing Uebersicht page, consuming the new GET teams/:id/overview/stats endpoint via a new TeamStatsApi service. Introduces a small reusable ChartCanvas shared component that wraps the Chart.js instance lifecycle via @Input()/ ngOnChanges, following this codebase's existing input-decorator convention rather than effect(). Co-Authored-By: Claude Sonnet 5 --- .../package-lock.json | 19 ++ myteamwallet_frontend_modern/package.json | 1 + .../src/app/core/team/team-stats-api.spec.ts | 35 ++++ .../src/app/core/team/team-stats-api.ts | 14 ++ .../app/features/team/overview/overview.html | 83 ++++++++ .../app/features/team/overview/overview.scss | 15 ++ .../features/team/overview/overview.spec.ts | 182 +++++++++++++++--- .../app/features/team/overview/overview.ts | 135 ++++++++++++- .../src/app/models/team-stats.model.ts | 22 +++ .../app/shared/chart-canvas/chart-canvas.html | 1 + .../app/shared/chart-canvas/chart-canvas.scss | 10 + .../shared/chart-canvas/chart-canvas.spec.ts | 104 ++++++++++ .../app/shared/chart-canvas/chart-canvas.ts | 81 ++++++++ 13 files changed, 674 insertions(+), 28 deletions(-) create mode 100644 myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/models/team-stats.model.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html create mode 100644 myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss create mode 100644 myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts diff --git a/myteamwallet_frontend_modern/package-lock.json b/myteamwallet_frontend_modern/package-lock.json index 8967254..611998e 100644 --- a/myteamwallet_frontend_modern/package-lock.json +++ b/myteamwallet_frontend_modern/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -2115,6 +2116,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -4639,6 +4646,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/myteamwallet_frontend_modern/package.json b/myteamwallet_frontend_modern/package.json index c94655e..1657c43 100644 --- a/myteamwallet_frontend_modern/package.json +++ b/myteamwallet_frontend_modern/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts new file mode 100644 index 0000000..15f4133 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TeamStatsApi } from './team-stats-api'; +import { environment } from '../../../environments/environment'; +import { TeamOverviewStats } from '../../models/team-stats.model'; + +describe('TeamStatsApi', () => { + let api: TeamStatsApi; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + api = TestBed.inject(TeamStatsApi); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('loads the overview stats for a team', () => { + const stats: TeamOverviewStats = { + balanceHistory: [{ month: '2026-07', balance: 125 }], + monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }], + topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }], + }; + + api.loadStats(5).subscribe((response) => expect(response).toEqual(stats)); + + const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview/stats`); + expect(request.request.method).toBe('GET'); + request.flush(stats); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts new file mode 100644 index 0000000..7e156da --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts @@ -0,0 +1,14 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { TeamOverviewStats } from '../../models/team-stats.model'; + +@Injectable({ providedIn: 'root' }) +export class TeamStatsApi { + private readonly http = inject(HttpClient); + + loadStats(teamId: number): Observable { + return this.http.get(`${environment.apiUrl}teams/${teamId}/overview/stats`); + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html index 473f40f..4835e8c 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html @@ -18,6 +18,89 @@ > + +
+
+
+

Kennzahlen

+

Kassenstand-Verlauf

+
+
+ + + @if (loadingStats()) { +
+ } @else if (balanceHistory().length === 0) { +
+ show_chartNoch keine Kassenstand-HistorieSobald Buchungen vorliegen, siehst du den Verlauf hier. +
+ } @else { +
+ +
+ } +
+
+
+ +
+
+
+

Kennzahlen

+

Einnahmen & Ausgaben

+
+
+ + + @if (loadingStats()) { +
+ } @else if (monthlyFlow().length === 0) { +
+ bar_chartNoch keine BewegungenEinnahmen und Ausgaben erscheinen hier pro Monat. +
+ } @else { +
+ +
+ } +
+
+
+ +
+
+
+

Kennzahlen

+

Offene Beiträge (Top 10)

+
+
+ + + @if (loadingStats()) { +
+ } @else if (topOutstanding().length === 0) { +
+ emoji_eventsKeine offenen BeiträgeAlle aktiven Mitglieder sind ausgeglichen. +
+ } @else { +
+ +
+ } + groupAlle Spieler ansehen +
+
+
+

Zuletzt passiert

diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss index 060a31e..d0df808 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss @@ -106,6 +106,21 @@ h2 { color: var(--mat-sys-on-surface-variant); text-align: center; } +.chart-block { + margin-bottom: 2rem; +} +.chart-card mat-card-content { + padding: 1rem 1.25rem 1.25rem; +} +.chart-canvas-box { + position: relative; + height: 220px; +} +.chart-card__link { + margin-top: 0.75rem; + display: flex; + justify-content: flex-end; +} @media (max-width: 520px) { .balance-grid { grid-template-columns: 1fr; diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts index 40add21..9a1e7c5 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts @@ -1,21 +1,69 @@ import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import { Overview } from './overview'; +import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas'; import { TeamStore } from '../../../core/team/team-store'; import { environment } from '../../../../environments/environment'; +import { TeamOverviewStats } from '../../../models/team-stats.model'; + +const { MockChart } = vi.hoisted(() => { + class MockChart { + static register = vi.fn(); + static instances: MockChart[] = []; + data: unknown; + options: unknown; + config: { type: unknown; data: unknown; options: unknown }; + destroy = vi.fn(); + update = vi.fn(); + + constructor( + public ctx: unknown, + config: { type: unknown; data: unknown; options: unknown }, + ) { + this.config = config; + this.data = config.data; + this.options = config.options; + MockChart.instances.push(this); + } + } + return { MockChart }; +}); + +vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); + +const sampleStats: TeamOverviewStats = { + balanceHistory: [ + { month: '2026-06', balance: 100 }, + { month: '2026-07', balance: 125 }, + ], + monthlyFlow: [ + { month: '2026-06', income: 50, expense: 10 }, + { month: '2026-07', income: 40, expense: 15 }, + ], + topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }], +}; + +const emptyStats: TeamOverviewStats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; describe('Overview', () => { - it('renders balances and the recent team activity', async () => { - const routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); + let routeParams: BehaviorSubject; + let httpMock: HttpTestingController; + let fixture: ComponentFixture; + + beforeEach(async () => { + MockChart.instances.length = 0; + routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); await TestBed.configureTestingModule({ imports: [Overview], providers: [ provideHttpClient(), provideHttpClientTesting(), + provideRouter([]), { provide: TeamStore, useValue: { @@ -34,21 +82,38 @@ describe('Overview', () => { }, ], }).compileComponents(); - const fixture = TestBed.createComponent(Overview); + httpMock = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(Overview); + }); + + function flushTransactions(activities: unknown[], teamId = 5): void { + httpMock.expectOne(`${environment.apiUrl}teams/${teamId}/transactions`).flush(activities); + } + + function flushStats(stats: TeamOverviewStats, teamId = 5): void { + httpMock.expectOne(`${environment.apiUrl}teams/${teamId}/overview/stats`).flush(stats); + } + + function failStats(teamId = 5): void { + httpMock + .expectOne(`${environment.apiUrl}teams/${teamId}/overview/stats`) + .flush(null, { status: 500, statusText: 'Server Error' }); + } + + it('renders balances and the recent team activity', async () => { fixture.detectChanges(); - TestBed.inject(HttpTestingController) - .expectOne(`${environment.apiUrl}teams/5/transactions`) - .flush([ - { - id: 1, - date: '2026-07-31', - amount: 12, - type: 'fine', - note: 'Beitrag', - playerName: 'Alex', - isTeamWalletTransaction: false, - }, - ]); + flushTransactions([ + { + id: 1, + date: '2026-07-31', + amount: 12, + type: 'fine', + note: 'Beitrag', + playerName: 'Alex', + isTeamWalletTransaction: false, + }, + ]); + flushStats(sampleStats); await fixture.whenStable(); fixture.detectChanges(); @@ -58,9 +123,8 @@ describe('Overview', () => { expect(fixture.nativeElement.textContent).toContain('-12,00'); routeParams.next(convertToParamMap({ id: '6' })); - TestBed.inject(HttpTestingController) - .expectOne(`${environment.apiUrl}teams/6/transactions`) - .flush([ + flushTransactions( + [ { id: 2, date: '2026-08-01', @@ -70,10 +134,84 @@ describe('Overview', () => { playerName: 'Bea', isTeamWalletTransaction: false, }, - ]); + ], + 6, + ); + flushStats(sampleStats, 6); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Neues Team'); expect(fixture.nativeElement.textContent).not.toContain('Beitrag'); }); + + it('shows a loading spinner in each chart card while stats are loading', () => { + fixture.detectChanges(); + + const spinners = fixture.nativeElement.querySelectorAll('.chart-block mat-spinner'); + expect(spinners.length).toBe(3); + + flushTransactions([]); + flushStats(sampleStats); + }); + + it('shows an empty state per chart when its dataset is an empty array', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(emptyStats); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Noch keine Kassenstand-Historie'); + expect(fixture.nativeElement.textContent).toContain('Noch keine Bewegungen'); + expect(fixture.nativeElement.textContent).toContain('Keine offenen Beiträge'); + expect(fixture.debugElement.queryAll(By.directive(ChartCanvas))).toHaveLength(0); + }); + + it('passes the loaded stats to each ChartCanvas once the request resolves', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(sampleStats); + await fixture.whenStable(); + fixture.detectChanges(); + + const charts = fixture.debugElement.queryAll(By.directive(ChartCanvas)); + expect(charts).toHaveLength(3); + + const [balanceChart, flowChart, outstandingChart] = charts.map( + (c) => c.componentInstance as ChartCanvas, + ); + expect(balanceChart.type).toBe('line'); + expect(balanceChart.data.labels).toHaveLength(2); + expect(flowChart.type).toBe('bar'); + expect(flowChart.data.datasets).toHaveLength(2); + expect(outstandingChart.type).toBe('bar'); + expect(outstandingChart.data.labels).toEqual(['Chris Beispiel']); + }); + + it('shows an empty state without throwing when the stats request errors', async () => { + fixture.detectChanges(); + flushTransactions([]); + + expect(() => failStats()).not.toThrow(); + await fixture.whenStable(); + expect(() => fixture.detectChanges()).not.toThrow(); + + expect(fixture.nativeElement.textContent).toContain('Noch keine Kassenstand-Historie'); + expect(fixture.nativeElement.textContent).toContain('Noch keine Bewegungen'); + expect(fixture.nativeElement.textContent).toContain('Keine offenen Beiträge'); + }); + + it('links the Top-10 card to the members route', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(sampleStats); + await fixture.whenStable(); + fixture.detectChanges(); + + const link: HTMLAnchorElement | null = fixture.nativeElement.querySelector( + '.chart-block--outstanding a[routerLink]', + ); + expect(link).toBeTruthy(); + expect(link?.getAttribute('routerLink')).toBe('../members'); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts index 760fe52..552cafc 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts @@ -1,23 +1,48 @@ import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; -import { Component, LOCALE_ID, inject, signal } from '@angular/core'; +import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import type { ChartData, ChartOptions } from 'chart.js'; +import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; +import { TeamStatsApi } from '../../../core/team/team-stats-api'; import { TeamActivity } from '../../../models/transaction.model'; +import { TeamOverviewStats } from '../../../models/team-stats.model'; import { signedTransactionAmount } from '../../../models/transaction-amount'; import { of } from 'rxjs'; import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; registerLocaleData(localeDe); +const BALANCE_COLOR = '#4f8f46'; +const INCOME_COLOR = '#4f8f46'; +const EXPENSE_COLOR = '#c1121f'; + +function formatMonthLabel(month: string): string { + const [year, monthNumber] = month.split('-').map(Number); + return new Intl.DateTimeFormat('de-DE', { month: 'short', year: '2-digit' }).format( + new Date(year, monthNumber - 1, 1), + ); +} + @Component({ selector: 'app-overview', - imports: [CurrencyPipe, DatePipe, MatCardModule, MatIconModule, MatProgressSpinnerModule], + imports: [ + CurrencyPipe, + DatePipe, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + RouterLink, + ChartCanvas, + ], providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }], templateUrl: './overview.html', styleUrl: './overview.scss', @@ -25,21 +50,101 @@ registerLocaleData(localeDe); export class Overview { private readonly route = inject(ActivatedRoute); private readonly transactionsApi = inject(TransactionsApi); + private readonly teamStatsApi = inject(TeamStatsApi); protected readonly team = inject(TeamStore).team; protected readonly activities = signal([]); protected readonly loadingActivities = signal(true); + protected readonly stats = signal(null); + protected readonly loadingStats = signal(true); + + protected readonly balanceHistory = computed(() => this.stats()?.balanceHistory ?? []); + protected readonly monthlyFlow = computed(() => this.stats()?.monthlyFlow ?? []); + protected readonly topOutstanding = computed(() => this.stats()?.topOutstanding ?? []); + + protected readonly balanceChartData = computed(() => { + const points = this.balanceHistory(); + return { + labels: points.map((point) => formatMonthLabel(point.month)), + datasets: [ + { + label: 'Kassenstand', + data: points.map((point) => point.balance), + borderColor: BALANCE_COLOR, + backgroundColor: BALANCE_COLOR, + tension: 0.3, + fill: false, + }, + ], + }; + }); + + protected readonly flowChartData = computed(() => { + const points = this.monthlyFlow(); + return { + labels: points.map((point) => formatMonthLabel(point.month)), + datasets: [ + { + label: 'Einnahmen', + data: points.map((point) => point.income), + backgroundColor: INCOME_COLOR, + }, + { + label: 'Ausgaben', + data: points.map((point) => point.expense), + backgroundColor: EXPENSE_COLOR, + }, + ], + }; + }); + + protected readonly topOutstandingChartData = computed(() => { + const players = this.topOutstanding(); + return { + labels: players.map((player) => player.playerName), + datasets: [ + { + label: 'Offener Betrag', + data: players.map((player) => player.balance), + backgroundColor: EXPENSE_COLOR, + }, + ], + }; + }); + + protected readonly balanceChartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + }; + + protected readonly flowChartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom' } }, + }; + + protected readonly topOutstandingChartOptions: ChartOptions = { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + }; constructor() { const parentRoute = this.route.parent; if (!parentRoute) { this.loadingActivities.set(false); + this.loadingStats.set(false); return; } - parentRoute.paramMap + const teamId$ = parentRoute.paramMap.pipe( + map((params) => Number(params.get('id'))), + distinctUntilChanged(), + ); + + teamId$ .pipe( - map((params) => Number(params.get('id'))), - distinctUntilChanged(), tap((id) => { this.activities.set([]); this.loadingActivities.set(Number.isInteger(id) && id > 0); @@ -55,6 +160,24 @@ export class Overview { this.activities.set(activities.slice(0, 10)); this.loadingActivities.set(false); }); + + teamId$ + .pipe( + tap((id) => { + this.stats.set(null); + this.loadingStats.set(Number.isInteger(id) && id > 0); + }), + switchMap((id) => + Number.isInteger(id) && id > 0 + ? this.teamStatsApi.loadStats(id).pipe(catchError(() => of(null))) + : of(null), + ), + takeUntilDestroyed(), + ) + .subscribe((stats) => { + this.stats.set(stats); + this.loadingStats.set(false); + }); } protected activityIcon(activity: TeamActivity): string { diff --git a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts new file mode 100644 index 0000000..c6694af --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts @@ -0,0 +1,22 @@ +export interface BalanceHistoryPoint { + month: string; + balance: number; +} + +export interface MonthlyFlowPoint { + month: string; + income: number; + expense: number; +} + +export interface TopOutstandingPlayer { + playerId: number; + playerName: string; + balance: number; +} + +export interface TeamOverviewStats { + balanceHistory: BalanceHistoryPoint[]; + monthlyFlow: MonthlyFlowPoint[]; + topOutstanding: TopOutstandingPlayer[]; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html @@ -0,0 +1 @@ + diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss new file mode 100644 index 0000000..78f1a56 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss @@ -0,0 +1,10 @@ +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} +canvas { + width: 100% !important; + height: 100% !important; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts new file mode 100644 index 0000000..b1b6d1d --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts @@ -0,0 +1,104 @@ +import { TestBed } from '@angular/core/testing'; +import { ChartCanvas } from './chart-canvas'; + +const { MockChart } = vi.hoisted(() => { + class MockChart { + static register = vi.fn(); + static instances: MockChart[] = []; + data: unknown; + options: unknown; + config: { type: unknown; data: unknown; options: unknown }; + destroy = vi.fn(); + update = vi.fn(); + + constructor( + public ctx: unknown, + config: { type: unknown; data: unknown; options: unknown }, + ) { + this.config = config; + this.data = config.data; + this.options = config.options; + MockChart.instances.push(this); + } + } + return { MockChart }; +}); + +vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); + +describe('ChartCanvas', () => { + afterEach(() => { + MockChart.instances.length = 0; + }); + + it('creates a Chart.js instance from the type/data/options inputs', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.componentRef.setInput('options', { responsive: true }); + fixture.detectChanges(); + + expect(MockChart.instances).toHaveLength(1); + const instance = MockChart.instances[0]; + expect(instance.config.type).toBe('line'); + expect(instance.config.data).toEqual({ labels: ['Jan'], datasets: [{ data: [1] }] }); + expect(instance.config.options).toEqual({ responsive: true }); + }); + + it('updates the chart instance in place when the data input changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'bar'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.componentRef.setInput('data', { labels: ['Feb'], datasets: [{ data: [2] }] }); + fixture.detectChanges(); + + expect(MockChart.instances).toHaveLength(1); + expect(instance.data).toEqual({ labels: ['Feb'], datasets: [{ data: [2] }] }); + expect(instance.update).toHaveBeenCalled(); + }); + + it('updates the chart instance when the options input changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'bar'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.componentRef.setInput('options', { responsive: true }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.componentRef.setInput('options', { responsive: false }); + fixture.detectChanges(); + + expect(instance.options).toEqual({ responsive: false }); + expect(instance.update).toHaveBeenCalled(); + }); + + it('recreates the chart instance when the chart type changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.detectChanges(); + const firstInstance = MockChart.instances[0]; + + fixture.componentRef.setInput('type', 'bar'); + fixture.detectChanges(); + + expect(firstInstance.destroy).toHaveBeenCalled(); + expect(MockChart.instances).toHaveLength(2); + expect(MockChart.instances[1].config.type).toBe('bar'); + }); + + it('destroys the chart instance when the component is destroyed', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: [], datasets: [] }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.destroy(); + + expect(instance.destroy).toHaveBeenCalled(); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts new file mode 100644 index 0000000..24fa86f --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts @@ -0,0 +1,81 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { + Chart, + ChartConfiguration, + ChartData, + ChartOptions, + ChartType, + registerables, +} from 'chart.js'; + +Chart.register(...registerables); + +/** + * Thin wrapper around a Chart.js instance bound to a ``. Chart-specific + * configuration (labels, datasets, colors, ...) is built by the caller and passed + * in via inputs — this component only manages the Chart.js instance lifecycle. + */ +@Component({ + selector: 'app-chart-canvas', + templateUrl: './chart-canvas.html', + styleUrl: './chart-canvas.scss', +}) +export class ChartCanvas implements AfterViewInit, OnChanges, OnDestroy { + @Input({ required: true }) type!: ChartType; + @Input({ required: true }) data!: ChartData; + @Input() options?: ChartOptions; + + @ViewChild('canvas', { static: true }) + private readonly canvasRef!: ElementRef; + + private chart?: Chart; + + ngAfterViewInit(): void { + this.createChart(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.chart) { + // Initial creation is handled by ngAfterViewInit once the canvas exists. + return; + } + + if (changes['type'] && !changes['type'].firstChange) { + this.chart.destroy(); + this.createChart(); + return; + } + + if (changes['data']) { + this.chart.data = this.data; + } + if (changes['options']) { + this.chart.options = this.options ?? {}; + } + if (changes['data'] || changes['options']) { + this.chart.update(); + } + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } + + private createChart(): void { + const config = { + type: this.type, + data: this.data, + options: this.options, + } as ChartConfiguration; + this.chart = new Chart(this.canvasRef.nativeElement, config); + } +} From 41a557f2c9cae35e676d6d1d171765752167193f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 20:33:22 +0200 Subject: [PATCH 3/5] test(overview): dedupe the MockChart test double into a shared helper The vi.mock('chart.js', ...) MockChart class was copy-pasted verbatim between chart-canvas.spec.ts and overview.spec.ts. Extract it to shared/chart-canvas/testing/mock-chart.ts and import it via vi.hoisted(async () => import(...)) in each spec, since vi.mock's factory is hoisted above regular imports and can't reference a plain top-level import. Co-Authored-By: Claude Sonnet 5 --- .../features/team/overview/overview.spec.ts | 27 +++------------- .../shared/chart-canvas/chart-canvas.spec.ts | 25 ++------------- .../shared/chart-canvas/testing/mock-chart.ts | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 44 deletions(-) create mode 100644 myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts index 9a1e7c5..cc28a55 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts @@ -11,28 +11,11 @@ import { TeamStore } from '../../../core/team/team-store'; import { environment } from '../../../../environments/environment'; import { TeamOverviewStats } from '../../../models/team-stats.model'; -const { MockChart } = vi.hoisted(() => { - class MockChart { - static register = vi.fn(); - static instances: MockChart[] = []; - data: unknown; - options: unknown; - config: { type: unknown; data: unknown; options: unknown }; - destroy = vi.fn(); - update = vi.fn(); - - constructor( - public ctx: unknown, - config: { type: unknown; data: unknown; options: unknown }, - ) { - this.config = config; - this.data = config.data; - this.options = config.options; - MockChart.instances.push(this); - } - } - return { MockChart }; -}); +// `vi.mock`'s factory is hoisted above regular imports, so the shared mock class is +// loaded via a dynamic import inside `vi.hoisted` rather than a plain top-level import. +const { MockChart } = await vi.hoisted( + async () => import('../../../shared/chart-canvas/testing/mock-chart'), +); vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts index b1b6d1d..e0d9d2e 100644 --- a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts @@ -1,28 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { ChartCanvas } from './chart-canvas'; -const { MockChart } = vi.hoisted(() => { - class MockChart { - static register = vi.fn(); - static instances: MockChart[] = []; - data: unknown; - options: unknown; - config: { type: unknown; data: unknown; options: unknown }; - destroy = vi.fn(); - update = vi.fn(); - - constructor( - public ctx: unknown, - config: { type: unknown; data: unknown; options: unknown }, - ) { - this.config = config; - this.data = config.data; - this.options = config.options; - MockChart.instances.push(this); - } - } - return { MockChart }; -}); +// `vi.mock`'s factory is hoisted above regular imports, so the shared mock class is +// loaded via a dynamic import inside `vi.hoisted` rather than a plain top-level import. +const { MockChart } = await vi.hoisted(async () => import('./testing/mock-chart')); vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts new file mode 100644 index 0000000..2210a22 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest'; + +/** + * Test double for Chart.js's `Chart` class, shared by `chart-canvas.spec.ts` and + * `overview.spec.ts`. jsdom has no canvas 2D context, so real Chart.js cannot render + * in this project's test environment — specs mock the whole `chart.js` module via + * `vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] }))` and assert + * on the Chart.js lifecycle contract (constructor args, update(), destroy()) instead. + * + * Not a `*.spec.ts` file on purpose: it exports a class rather than defining tests, + * so it must not be picked up by the test runner's `**\/*.spec.ts` include glob. + */ +export class MockChart { + static register = vi.fn(); + static instances: MockChart[] = []; + + data: unknown; + options: unknown; + config: { type: unknown; data: unknown; options: unknown }; + destroy = vi.fn(); + update = vi.fn(); + + constructor( + public ctx: unknown, + config: { type: unknown; data: unknown; options: unknown }, + ) { + this.config = config; + this.data = config.data; + this.options = config.options; + MockChart.instances.push(this); + } +} From 1058d641e78f23825ea7ce3e98f4d361828e152c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 21:02:32 +0200 Subject: [PATCH 4/5] fix(teams): anchor balanceHistory to team.balance instead of forward-summing Manual verification against real data (Task 3) found balanceHistory drifted from team.balance for real teams, since team.balance carries historical adjustments (e.g. from the removed legacy backend) that don't trace back to the current payment/credit/expense rows. Forward-summing those rows from zero could never be trusted to tie out. Rewrite balanceHistory to anchor on team.balance (the authoritative current value) and walk the movements backward, newest to oldest, undoing each one to reconstruct earlier month-end balances. This guarantees the most recent point equals team.balance by construction, and is mathematically identical to the old forward sum for teams whose movements fully explain their balance. monthlyFlow/topOutstanding are unaffected and left as-is. Updated teams.service.spec.ts to use a fixture where team.balance intentionally does not equal the sum of its own movements, so the tests actually exercise the drift-handling behavior instead of a case where forward-sum and backward-anchor happen to coincide. Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.service.spec.ts | 50 +++++++++++++------ .../src/teams/teams.service.ts | 39 +++++++++++---- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index b8ccb21..94039a1 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -27,10 +27,21 @@ describe('TeamsService', () => { describe('getOverviewStats', () => { // "now" is fixed to 2026-08-15, so the 12-month window covers // 2025-09 .. 2026-08. + // + // team.balance is deliberately set to 490, NOT 190 (the sum of this + // fixture's own movements). This simulates real-world drift: in + // production, team.balance can include historical adjustments (e.g. + // from the legacy backend removed in commit 9664187) that don't trace + // back to the currently-visible payment/credit/expense rows. The +300 + // offset must show up on every reconstructed balanceHistory point + // (anchored backward from team.balance), proving the implementation + // walks backward from the authoritative team.balance rather than + // forward-summing the movements from zero. + const DRIFT = 300; function buildTeam() { return { id: 7, - balance: 190, + balance: 190 + DRIFT, transactions: [ // TeamWalletTransaction: credit adds, expense subtracts. { @@ -132,33 +143,42 @@ describe('TeamsService', () => { jest.useRealTimers(); }); - it('builds a 12-month balanceHistory with carry-forward and correct sign handling', async () => { + it('anchors balanceHistory to team.balance and reconstructs earlier months backward, with carry-forward and correct sign handling', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); const result = await service.getOverviewStats('7'); + // Same relative shape as the movements alone would produce + // (100, 150, 150, 150, 150, 110, 130, 130, 130, 130, 190, 190), but + // every point is shifted by the fixture's +300 drift because the + // series is anchored backward from team.balance, not forward-summed + // from zero. expect(result.balanceHistory).toEqual([ - { month: '2025-09', balance: 100 }, - { month: '2025-10', balance: 150 }, - { month: '2025-11', balance: 150 }, - { month: '2025-12', balance: 150 }, - { month: '2026-01', balance: 150 }, - { month: '2026-02', balance: 110 }, - { month: '2026-03', balance: 130 }, - { month: '2026-04', balance: 130 }, - { month: '2026-05', balance: 130 }, - { month: '2026-06', balance: 130 }, - { month: '2026-07', balance: 190 }, - { month: '2026-08', balance: 190 }, + { month: '2025-09', balance: 100 + DRIFT }, + { month: '2025-10', balance: 150 + DRIFT }, + { month: '2025-11', balance: 150 + DRIFT }, + { month: '2025-12', balance: 150 + DRIFT }, + { month: '2026-01', balance: 150 + DRIFT }, + { month: '2026-02', balance: 110 + DRIFT }, + { month: '2026-03', balance: 130 + DRIFT }, + { month: '2026-04', balance: 130 + DRIFT }, + { month: '2026-05', balance: 130 + DRIFT }, + { month: '2026-06', balance: 130 + DRIFT }, + { month: '2026-07', balance: 190 + DRIFT }, + { month: '2026-08', balance: 190 + DRIFT }, ]); }); - it('sanity check: the last balanceHistory entry equals team.balance', async () => { + it('sanity check: the last balanceHistory entry equals team.balance, even when team.balance does not equal the sum of the movements', async () => { const team = buildTeam(); repository.findOneOrFail.mockResolvedValue(team); const result = await service.getOverviewStats('7'); + // Sum of this fixture's movements is 190, but team.balance is 490 — + // if the sanity check passes, the implementation is anchored to + // team.balance rather than forward-summing the movements. + expect(team.balance).not.toBe(190); expect(result.balanceHistory.at(-1).balance).toBe(team.balance); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index b883c7d..e18fca5 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -252,18 +252,35 @@ export class TeamsService { const months = this.getLast12Months(); - let cumulativeBalance = 0; + // team.balance is the one authoritative, current value — it can include + // historical adjustments (e.g. from the removed legacy backend) that + // don't trace back to the visible payment/credit/expense rows. Forward- + // summing the movements from zero would silently drift away from + // team.balance for such teams. Instead we anchor to team.balance and + // walk the movements backward (newest first), "undoing" each one to + // reconstruct earlier month-end balances — this guarantees the most + // recent point always equals team.balance by construction, regardless + // of undocumented history. + const descendingMovements = [...movements].sort((a, b) => + a.date > b.date ? -1 : a.date < b.date ? 1 : 0, + ); + + const currentBalance = Number(team.balance); + let futureSum = 0; let movementIndex = 0; - const balanceHistory = months.map((month) => { - while ( - movementIndex < movements.length && - movements[movementIndex].date.slice(0, 7) <= month - ) { - cumulativeBalance += this.signedFlowAmount(movements[movementIndex]); - movementIndex++; - } - return { month, balance: this.round(cumulativeBalance) }; - }); + const balanceHistory = [...months] + .reverse() + .map((month) => { + while ( + movementIndex < descendingMovements.length && + descendingMovements[movementIndex].date.slice(0, 7) > month + ) { + futureSum += this.signedFlowAmount(descendingMovements[movementIndex]); + movementIndex++; + } + return { month, balance: this.round(currentBalance - futureSum) }; + }) + .reverse(); const monthlyFlow = months.map((month) => { const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month); From e2f271fd357ab7f5d6377744fb0fadb083eff8ae Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 21:41:54 +0200 Subject: [PATCH 5/5] fix(teams): gate empty-state and add membership check to overview stats Whole-branch review findings: 1. balanceHistory/monthlyFlow always returned 12 entries, even for a brand-new team with zero transactions, so the frontend's empty-state (gated on .length === 0) could never fire for a real "no movements yet" team. Now returns empty arrays when there are no relevant movements at all (not just none in the last 12 months, so a team with older-but-real history still gets a flat chart). Also added the same defensive `?? []` guard on players/transactions that getOverview already has, so a team with no players/relations loaded doesn't throw. 2. GET :id/overview/stats had no team-membership check -- any logged-in user (RoleEnum.user is the default role) could read any other team's financial stats by iterating ids. Injected TeamAccessService into TeamsService (already a sibling provider in TeamsModule, no module wiring needed) and call assertMember(actorUserId, teamId) as the first line of getOverviewStats, threaded from the controller via @Req(). Read access only (assertMember, not assertManager), matching who can already view the overview page. Sibling routes with the same pre-existing gap were left untouched, per review scope. Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.controller.spec.ts | 7 +- .../src/teams/teams.controller.ts | 5 +- .../src/teams/teams.service.spec.ts | 83 +++++++++++++++++-- .../src/teams/teams.service.ts | 47 +++++++---- 4 files changed, 117 insertions(+), 25 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.controller.spec.ts b/myteamwallet_backend/src/teams/teams.controller.spec.ts index fb0f7e5..75f93d2 100644 --- a/myteamwallet_backend/src/teams/teams.controller.spec.ts +++ b/myteamwallet_backend/src/teams/teams.controller.spec.ts @@ -40,13 +40,14 @@ describe('TeamsController', () => { ).toEqual(Reflect.getMetadata('roles', TeamsController.prototype.findOne)); }); - it('delegates to service.getOverviewStats with the route id', () => { + it('delegates to service.getOverviewStats with the route id and the acting user id', () => { const stats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; service.getOverviewStats.mockReturnValue(stats); + const req = { user: { id: 42 } }; - const result = controller.getOverviewStats('7'); + const result = controller.getOverviewStats(req as any, '7'); - expect(service.getOverviewStats).toHaveBeenCalledWith('7'); + expect(service.getOverviewStats).toHaveBeenCalledWith('7', 42); expect(result).toBe(stats); }); }); diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index 7ffdf10..1ac20de 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -91,8 +91,9 @@ export class TeamsController { @UseGuards(AuthGuard('jwt'), RolesGuard) @Get(':id/overview/stats') @HttpCode(HttpStatus.OK) - getOverviewStats(@Param('id') id: string) { - return this.service.getOverviewStats(id); + getOverviewStats(@Req() req, @Param('id') id: string) { + const userId = req.user?.id; + return this.service.getOverviewStats(id, userId); } @ApiOperation({ diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 94039a1..4a3232f 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -1,3 +1,4 @@ +import { ForbiddenException } from '@nestjs/common'; import { TeamsService } from './teams.service'; describe('TeamsService', () => { @@ -8,11 +9,13 @@ describe('TeamsService', () => { const settingsRepository = {}; const teamWalletTransactionRepository = {}; const logger = { info: jest.fn(), debug: jest.fn() }; + const access = { assertMember: jest.fn(), assertManager: jest.fn() }; let service: TeamsService; beforeEach(() => { jest.resetAllMocks(); + access.assertMember.mockResolvedValue(undefined); service = new TeamsService( repository as any, playerRepository as any, @@ -21,6 +24,7 @@ describe('TeamsService', () => { settingsRepository as any, teamWalletTransactionRepository as any, logger as any, + access as any, ); }); @@ -146,7 +150,7 @@ describe('TeamsService', () => { it('anchors balanceHistory to team.balance and reconstructs earlier months backward, with carry-forward and correct sign handling', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); // Same relative shape as the movements alone would produce // (100, 150, 150, 150, 150, 110, 130, 130, 130, 130, 190, 190), but @@ -173,7 +177,7 @@ describe('TeamsService', () => { const team = buildTeam(); repository.findOneOrFail.mockResolvedValue(team); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); // Sum of this fixture's movements is 190, but team.balance is 490 — // if the sanity check passes, the implementation is anchored to @@ -185,7 +189,7 @@ describe('TeamsService', () => { it('groups monthlyFlow into income (payment+credit) and expense, excluding fine/levy/fee', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.monthlyFlow).toEqual([ { month: '2025-09', income: 100, expense: 0 }, @@ -206,7 +210,7 @@ describe('TeamsService', () => { it('limits topOutstanding to active players with negative balance, sorted by debt descending', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.topOutstanding).toEqual([ { playerId: 102, playerName: 'Bea Berg', balance: 75 }, @@ -226,7 +230,7 @@ describe('TeamsService', () => { })); repository.findOneOrFail.mockResolvedValue(team); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.topOutstanding).toHaveLength(10); // highest debt first @@ -240,12 +244,79 @@ describe('TeamsService', () => { it('loads the team with the same relations used by getTeamTransactions', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - await service.getOverviewStats('7'); + await service.getOverviewStats('7', 42); expect(repository.findOneOrFail).toHaveBeenCalledWith({ where: { id: 7 }, relations: ['players', 'players.transactions', 'transactions'], }); }); + + it('checks team membership via TeamAccessService.assertMember before loading the team', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + await service.getOverviewStats('7', 42); + + expect(access.assertMember).toHaveBeenCalledWith(42, 7); + }); + + it('propagates a ForbiddenException from assertMember without loading the team', async () => { + const forbidden = new ForbiddenException('Keine Berechtigung für dieses Team.'); + access.assertMember.mockRejectedValue(forbidden); + + await expect(service.getOverviewStats('7', 42)).rejects.toBe(forbidden); + expect(repository.findOneOrFail).not.toHaveBeenCalled(); + }); + + it('returns empty balanceHistory/monthlyFlow/topOutstanding for a brand-new team with no transactions and no players, without throwing', async () => { + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 0, + transactions: [], + players: [], + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result).toEqual({ balanceHistory: [], monthlyFlow: [], topOutstanding: [] }); + }); + + it('does not throw when the players/transactions relations come back undefined (defensive guard, matches getOverview)', async () => { + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 0, + transactions: undefined, + players: undefined, + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result).toEqual({ balanceHistory: [], monthlyFlow: [], topOutstanding: [] }); + }); + + it('still returns a real (flat) chart, not an empty-state, when the last movement is outside the 12-month window', async () => { + // Only relevant movement is dated ~2 years ago -> outside the 12-month + // window (2025-09..2026-08), but movements.length > 0, so this must + // NOT trigger the empty-state — it's a real (if flat) history. + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 250, + transactions: [ + { + id: 1, + date: '2024-01-15T10:00:00.000Z', + amount: '250.00', + type: { name: 'credit' }, + }, + ], + players: [], + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result.balanceHistory).toHaveLength(12); + expect(result.balanceHistory.every((entry) => entry.balance === 250)).toBe(true); + expect(result.monthlyFlow).toHaveLength(12); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index e18fca5..8f1533f 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; import { CreateTeamDTO } from './dto/create-team.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { Team } from './entities/team.entity'; +import { TeamAccessService } from './team-access.service'; @Injectable() export class TeamsService { @@ -28,6 +29,7 @@ export class TeamsService { @InjectRepository(TeamWalletTransaction) private teamWalletTransactionRepository: Repository, private logger: LoggingService, + private access: TeamAccessService, ) {} async getOverview(teamId: string) { @@ -218,30 +220,40 @@ export class TeamsService { return result; } - async getOverviewStats(teamId: string | number): Promise<{ + async getOverviewStats( + teamId: string | number, + actorUserId: string | number, + ): Promise<{ balanceHistory: { month: string; balance: number }[]; monthlyFlow: { month: string; income: number; expense: number }[]; topOutstanding: { playerId: number; playerName: string; balance: number }[]; }> { const id = Number(teamId); + // Read access: any active team member may view the KPI charts (same + // audience as the existing :id/overview page), not just managers. + await this.access.assertMember(Number(actorUserId), id); + const team = await this.repository.findOneOrFail({ where: { id }, relations: ['players', 'players.transactions', 'transactions'], }); + const players = team.players ?? []; + const teamTransactions = team.transactions ?? []; + // Only payment/credit/expense movements represent actual cash flow and // are the only types that touch team.balance (see setBalance() on // TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's // debt but never move money, so they are excluded entirely. const movements: { date: string; amount: number; type: string }[] = []; - for (const t of team.transactions) { + for (const t of teamTransactions) { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); } - for (const p of team.players) { - for (const t of p.transactions) { + for (const p of players) { + for (const t of p.transactions ?? []) { if (t.type.name === 'payment') { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); } @@ -250,6 +262,23 @@ export class TeamsService { movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + const topOutstanding = players + .filter((p) => p.active && Number(p.balance) < 0) + .sort((a, b) => Number(a.balance) - Number(b.balance)) + .slice(0, 10) + .map((p) => ({ + playerId: p.id, + playerName: p.firstName + ' ' + p.lastName, + balance: this.round(Math.abs(Number(p.balance))), + })); + + // A brand-new team with no relevant movements at all (ever, not just in + // the last 12 months) has nothing to chart — return empty arrays so the + // frontend's empty-state fires instead of rendering 12 flat zero points. + if (movements.length === 0) { + return { balanceHistory: [], monthlyFlow: [], topOutstanding }; + } + const months = this.getLast12Months(); // team.balance is the one authoritative, current value — it can include @@ -293,16 +322,6 @@ export class TeamsService { return { month, income: this.round(income), expense: this.round(expense) }; }); - const topOutstanding = team.players - .filter((p) => p.active && Number(p.balance) < 0) - .sort((a, b) => Number(a.balance) - Number(b.balance)) - .slice(0, 10) - .map((p) => ({ - playerId: p.id, - playerName: p.firstName + ' ' + p.lastName, - balance: this.round(Math.abs(Number(p.balance))), - })); - return { balanceHistory, monthlyFlow, topOutstanding }; }