Merge branch 'worktree-kasse-kpi-charts'

This commit is contained in:
Bastian Wagner
2026-08-01 21:49:07 +02:00
16 changed files with 805 additions and 28 deletions

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