This commit is contained in:
Mateusz Gruszczyński
2026-04-07 11:41:05 +02:00
parent ca9c78d88d
commit dda1c47764
5 changed files with 455 additions and 255 deletions

View File

@@ -1,16 +1,23 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Chart, ArcElement, DoughnutController, Legend, Tooltip } from 'chart.js';
import { AfterViewChecked, Component, OnDestroy, OnInit, effect, inject } from '@angular/core';
import { Chart, ArcElement, CategoryScale, DoughnutController, Legend, LineController, LineElement, LinearScale, PointElement, Tooltip } from 'chart.js';
import { AuthService } from '../../core/services/auth.service';
import { ExpensesService } from '../../core/services/expenses.service';
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
import { StatsService } from '../../core/services/stats.service';
import { UiService } from '../../core/services/ui.service';
import type { CashflowResponse, Expense, ShoppingListSummary, StatsResponse } from '../../shared/models';
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
Chart.register(DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale);
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v6';
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v7';
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
type TimelineRangeKey = '1m' | '2m' | '3m' | 'q' | '6m' | '12m';
type TimelineRangeOption = {
value: TimelineRangeKey;
labelKey: string;
};
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
@@ -19,10 +26,27 @@ const formatLocalDate = (date: Date) => {
return `${year}-${month}-${day}`;
};
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
const addMonths = (date: Date, amount: number) => {
const next = new Date(date);
next.setMonth(next.getMonth() + amount, 1);
return next;
};
const getMonthRange = () => {
const today = new Date();
return {
start: formatLocalDate(new Date(today.getFullYear(), today.getMonth(), 1)),
start: formatLocalDate(startOfMonth(today)),
end: formatLocalDate(today)
};
};
const getTimelineRange = (range: TimelineRangeKey) => {
const today = new Date();
const monthsBack = ({ '1m': 0, '2m': 1, '3m': 2, q: 2, '6m': 5, '12m': 11 } as Record<TimelineRangeKey, number>)[range] ?? 0;
return {
start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))),
end: formatLocalDate(today)
};
};
@@ -47,8 +71,36 @@ const getMonthRange = () => {
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
@if (canShowExternalStats()) {
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
}
<div class="col-12 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
<div class="btn-list">
@for (option of timelineRangeOptions; track option.value) {
<button class="btn btn-sm"
type="button"
[class.btn-primary]="selectedTimelineRange === option.value"
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
(click)="changeTimelineRange(option.value)">
{{ ui.t(option.labelKey) }}
</button>
}
</div>
</div>
<div class="card-body">
@if (timelineStats?.timeline?.length) {
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-7 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
@@ -133,39 +185,118 @@ const getMonthRange = () => {
})
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
readonly ui = inject(UiService);
readonly auth = inject(AuthService);
private readonly expensesService = inject(ExpensesService);
private readonly statsService = inject(StatsService);
private readonly shoppingIntegration = inject(ShoppingListIntegrationService);
readonly timelineRangeOptions: TimelineRangeOption[] = [
{ value: '1m', labelKey: 'dashboard.range.1m' },
{ value: '2m', labelKey: 'dashboard.range.2m' },
{ value: '3m', labelKey: 'dashboard.range.3m' },
{ value: 'q', labelKey: 'dashboard.range.q' },
{ value: '6m', labelKey: 'dashboard.range.6m' },
{ value: '12m', labelKey: 'dashboard.range.12m' }
];
recentExpenses: Expense[] = [];
stats: StatsResponse | null = null;
timelineStats: StatsResponse | null = null;
cashflow: CashflowResponse | null = null;
externalSummary: ShoppingListSummary | null = null;
selectedTimelineRange: TimelineRangeKey = '3m';
private categoryChart?: Chart;
private chartPending = false;
private timelineChart?: Chart;
private categoryChartPending = false;
private timelineChartPending = false;
constructor() {
effect(() => {
if (this.canShowExternalStats()) this.loadExternalSummary();
else {
this.externalSummary = null;
this.persistCache();
}
});
}
ngOnInit() {
this.restoreCache();
this.loadDashboard();
this.loadTimeline();
}
ngAfterViewChecked() {
if (this.chartPending) {
this.chartPending = false;
this.renderChart();
if (this.categoryChartPending) {
this.categoryChartPending = false;
this.renderCategoryChart();
}
if (this.timelineChartPending) {
this.timelineChartPending = false;
this.renderTimelineChart();
}
}
ngOnDestroy() {
this.categoryChart?.destroy();
this.timelineChart?.destroy();
}
canShowExternalStats() {
return Boolean(this.auth.currentUser()?.integrationsEnabled);
}
loadDashboard() {
const range = getMonthRange();
this.expensesService.list().subscribe({ next: (response) => { this.recentExpenses = response.items.slice(0, 8); this.persistCache(); } });
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ next: (response) => { this.stats = response; this.chartPending = true; this.persistCache(); } });
this.statsService.cashflow().subscribe({ next: (response) => { this.cashflow = response; this.persistCache(); } });
this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({ next: (response) => { this.externalSummary = response; this.persistCache(); }, error: () => { this.externalSummary = this.externalSummary ?? null; } });
this.expensesService.list().subscribe({
next: (response) => {
this.recentExpenses = response.items.slice(0, 8);
this.persistCache();
}
});
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({
next: (response) => {
this.stats = response;
this.categoryChartPending = true;
this.persistCache();
}
});
this.statsService.cashflow().subscribe({
next: (response) => {
this.cashflow = response;
this.persistCache();
}
});
}
changeTimelineRange(range: TimelineRangeKey) {
if (this.selectedTimelineRange === range) return;
this.selectedTimelineRange = range;
this.loadTimeline();
}
loadTimeline() {
const range = getTimelineRange(this.selectedTimelineRange);
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({
next: (response) => {
this.timelineStats = response;
this.timelineChartPending = true;
this.persistCache();
}
});
}
private loadExternalSummary() {
const range = getMonthRange();
this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({
next: (response) => {
this.externalSummary = response;
this.persistCache();
},
error: () => {
this.externalSummary = this.externalSummary ?? null;
}
});
}
externalAmount() { return Number(this.externalSummary?.total ?? this.externalSummary?.amount ?? this.externalSummary?.meta?.total_amount ?? 0); }
@@ -180,11 +311,7 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
}[status] || 'text-bg-secondary';
}
private scheduleChartRender() {
requestAnimationFrame(() => this.renderChart());
}
private renderChart() {
private renderCategoryChart() {
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
if (!canvas || !this.stats?.byCategory?.length) {
this.categoryChart?.destroy();
@@ -208,22 +335,69 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
});
}
private renderTimelineChart() {
const canvas = document.getElementById('dashboardTimelineChart') as HTMLCanvasElement | null;
if (!canvas || !this.timelineStats?.timeline?.length) {
this.timelineChart?.destroy();
return;
}
this.timelineChart?.destroy();
this.timelineChart = new Chart(canvas, {
type: 'line',
data: {
labels: this.timelineStats.timeline.map((item) => item.label),
datasets: [{
label: this.ui.t('stats.expensesLabel'),
data: this.timelineStats.timeline.map((item) => item.total),
borderColor: '#206bc4',
backgroundColor: '#206bc4',
tension: 0.25,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } }
}
});
}
private restoreCache() {
try {
const raw = localStorage.getItem(DASHBOARD_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null; cashflow?: CashflowResponse | null; externalSummary?: ShoppingListSummary | null };
const parsed = JSON.parse(raw) as {
recentExpenses?: Expense[];
stats?: StatsResponse | null;
timelineStats?: StatsResponse | null;
cashflow?: CashflowResponse | null;
externalSummary?: ShoppingListSummary | null;
selectedTimelineRange?: TimelineRangeKey;
};
this.recentExpenses = parsed.recentExpenses ?? [];
this.stats = parsed.stats ?? null;
this.timelineStats = parsed.timelineStats ?? null;
this.cashflow = parsed.cashflow ?? null;
this.externalSummary = parsed.externalSummary ?? null;
this.chartPending = Boolean(this.stats?.byCategory?.length);
this.selectedTimelineRange = parsed.selectedTimelineRange ?? '3m';
this.categoryChartPending = Boolean(this.stats?.byCategory?.length);
this.timelineChartPending = Boolean(this.timelineStats?.timeline?.length);
} catch {}
}
private persistCache() {
try {
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats, cashflow: this.cashflow, externalSummary: this.externalSummary }));
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({
recentExpenses: this.recentExpenses,
stats: this.stats,
timelineStats: this.timelineStats,
cashflow: this.cashflow,
externalSummary: this.externalSummary,
selectedTimelineRange: this.selectedTimelineRange
}));
} catch {}
}
}