Compare commits

...

6 Commits

Author SHA1 Message Date
Bastian Wagner
a94caed85f Merge branch 'worktree-kasse-kpi-charts' 2026-08-01 21:49:07 +02:00
Bastian Wagner
e2f271fd35 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 <noreply@anthropic.com>
2026-08-01 21:41:54 +02:00
Bastian Wagner
1058d641e7 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 <noreply@anthropic.com>
2026-08-01 21:02:32 +02:00
Bastian Wagner
41a557f2c9 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 <noreply@anthropic.com>
2026-08-01 20:33:22 +02:00
Bastian Wagner
435b8d5c53 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 <noreply@anthropic.com>
2026-08-01 20:21:50 +02:00
Bastian Wagner
ecb6fd4394 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 <noreply@anthropic.com>
2026-08-01 19:44:49 +02:00
16 changed files with 805 additions and 28 deletions

View File

@@ -86,6 +86,16 @@ 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(@Req() req, @Param('id') id: string) {
const userId = req.user?.id;
return this.service.getOverviewStats(id, userId);
}
@ApiOperation({
summary: 'Transactionen für ein Team',
description:

View File

@@ -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<TeamWalletTransaction>,
private logger: LoggingService,
private access: TeamAccessService,
) {}
async getOverview(teamId: string) {
@@ -218,6 +220,129 @@ export class TeamsService {
return result;
}
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 teamTransactions) {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
}
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 });
}
}
}
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
// 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]
.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);
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) };
});
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: {

View File

@@ -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",

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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<TeamOverviewStats> {
return this.http.get<TeamOverviewStats>(`${environment.apiUrl}teams/${teamId}/overview/stats`);
}
}

View File

@@ -18,6 +18,89 @@
></mat-card
>
</div>
<section class="chart-block chart-block--balance">
<div class="section-heading">
<div>
<p class="eyebrow">Kennzahlen</p>
<h2>Kassenstand-Verlauf</h2>
</div>
</div>
<mat-card class="chart-card">
<mat-card-content>
@if (loadingStats()) {
<div class="state"><mat-spinner diameter="32" /></div>
} @else if (balanceHistory().length === 0) {
<div class="state">
<mat-icon>show_chart</mat-icon><strong>Noch keine Kassenstand-Historie</strong
><span>Sobald Buchungen vorliegen, siehst du den Verlauf hier.</span>
</div>
} @else {
<div class="chart-canvas-box">
<app-chart-canvas type="line" [data]="balanceChartData()" [options]="balanceChartOptions" />
</div>
}
</mat-card-content>
</mat-card>
</section>
<section class="chart-block chart-block--flow">
<div class="section-heading">
<div>
<p class="eyebrow">Kennzahlen</p>
<h2>Einnahmen &amp; Ausgaben</h2>
</div>
</div>
<mat-card class="chart-card">
<mat-card-content>
@if (loadingStats()) {
<div class="state"><mat-spinner diameter="32" /></div>
} @else if (monthlyFlow().length === 0) {
<div class="state">
<mat-icon>bar_chart</mat-icon><strong>Noch keine Bewegungen</strong
><span>Einnahmen und Ausgaben erscheinen hier pro Monat.</span>
</div>
} @else {
<div class="chart-canvas-box">
<app-chart-canvas type="bar" [data]="flowChartData()" [options]="flowChartOptions" />
</div>
}
</mat-card-content>
</mat-card>
</section>
<section class="chart-block chart-block--outstanding">
<div class="section-heading">
<div>
<p class="eyebrow">Kennzahlen</p>
<h2>Offene Beiträge (Top 10)</h2>
</div>
</div>
<mat-card class="chart-card">
<mat-card-content>
@if (loadingStats()) {
<div class="state"><mat-spinner diameter="32" /></div>
} @else if (topOutstanding().length === 0) {
<div class="state">
<mat-icon>emoji_events</mat-icon><strong>Keine offenen Beiträge</strong
><span>Alle aktiven Mitglieder sind ausgeglichen.</span>
</div>
} @else {
<div class="chart-canvas-box">
<app-chart-canvas
type="bar"
[data]="topOutstandingChartData()"
[options]="topOutstandingChartOptions"
/>
</div>
}
<a mat-button class="chart-card__link" routerLink="../members"
><mat-icon>group</mat-icon>Alle Spieler ansehen</a
>
</mat-card-content>
</mat-card>
</section>
<div class="section-heading">
<div>
<p class="eyebrow">Zuletzt passiert</p>

View File

@@ -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;

View File

@@ -1,21 +1,52 @@
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';
// `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: [] }));
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<ParamMap>;
let httpMock: HttpTestingController;
let fixture: ComponentFixture<Overview>;
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 +65,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 +106,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 +117,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');
});
});

View File

@@ -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<TeamActivity[]>([]);
protected readonly loadingActivities = signal(true);
protected readonly stats = signal<TeamOverviewStats | null>(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<ChartData>(() => {
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<ChartData>(() => {
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<ChartData>(() => {
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 {

View File

@@ -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[];
}

View File

@@ -0,0 +1 @@
<canvas #canvas></canvas>

View File

@@ -0,0 +1,10 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
}
canvas {
width: 100% !important;
height: 100% !important;
}

View File

@@ -0,0 +1,85 @@
import { TestBed } from '@angular/core/testing';
import { ChartCanvas } from './chart-canvas';
// `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: [] }));
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();
});
});

View File

@@ -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 `<canvas>`. 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<HTMLCanvasElement>;
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);
}
}

View File

@@ -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);
}
}