From 435b8d5c53760f095fc2910e948fca667402436b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 20:21:50 +0200 Subject: [PATCH] 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); + } +}