337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { ChangeDetectorRef, Component, inject, AfterViewChecked, OnDestroy, OnInit, ViewChild, ElementRef, NgZone } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Chart, registerables } from 'chart.js';
|
|
import { BaseChartDirective } from 'ng2-charts';
|
|
import L from 'leaflet';
|
|
import { firstValueFrom } from 'rxjs';
|
|
|
|
import { StatsService } from '../services/stats.service';
|
|
import {
|
|
StatsSummary,
|
|
TimeSeriesPoint,
|
|
GeoStat,
|
|
TimePreset,
|
|
} from '../interfaces/visitor-stat';
|
|
|
|
import { getAppConfig } from '../services/app-config.service';
|
|
|
|
// Register Chart.js components once
|
|
Chart.register(...registerables);
|
|
|
|
@Component({
|
|
selector: 'app-admin-stats-dashboard',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule, BaseChartDirective],
|
|
templateUrl: './admin-stats-dashboard.component.html',
|
|
styleUrl: './admin-stats-dashboard.component.scss',
|
|
})
|
|
export class AdminStatsDashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
private readonly statsService: StatsService = inject(StatsService);
|
|
private readonly cd = inject(ChangeDetectorRef);
|
|
private readonly ngZone = inject(NgZone);
|
|
|
|
@ViewChild('mapContainer', { static: false }) mapContainerRef!: ElementRef;
|
|
|
|
// ── Time range ──────────────────────────────────────────
|
|
readonly presets = [
|
|
{ label: 'Yesterday', value: 'yesterday' as TimePreset },
|
|
{ label: '7 Days', value: '7d' as TimePreset },
|
|
{ label: '30 Days', value: '30d' as TimePreset },
|
|
{ label: '90 Days', value: '90d' as TimePreset },
|
|
{ label: 'Year', value: 'year' as TimePreset },
|
|
{ label: 'Custom', value: 'custom' as TimePreset },
|
|
];
|
|
selectedPreset: TimePreset = '30d';
|
|
customStart = '';
|
|
customEnd = '';
|
|
|
|
// ── Data ────────────────────────────────────────────────
|
|
summary: StatsSummary | null = null;
|
|
timeSeries: TimeSeriesPoint[] = [];
|
|
geoStats: GeoStat[] = [];
|
|
loading = true;
|
|
error: string | null = null;
|
|
parsing = false;
|
|
parseProgress = '';
|
|
|
|
// ── Chart ───────────────────────────────────────────────
|
|
chartData: { labels: string[]; datasets: any[] } = { labels: [], datasets: [] };
|
|
chartOptions: any = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: true, position: 'top' as const },
|
|
},
|
|
scales: {
|
|
x: { title: { display: true, text: 'Date' } },
|
|
y: { title: { display: true, text: 'Visitors' }, beginAtZero: true },
|
|
},
|
|
};
|
|
|
|
// ── Leaflet ─────────────────────────────────────────────
|
|
private map: L.Map | null = null;
|
|
private markersLayer: L.LayerGroup | null = null;
|
|
private mapInitialized = false;
|
|
|
|
ngOnInit(): void {
|
|
this.loadAll();
|
|
}
|
|
|
|
ngAfterViewChecked(): void {
|
|
// The map container is inside an @else block gated by loading=true.
|
|
// ngAfterViewInit fires before loading completes, so the element
|
|
// doesn't exist yet. ngAfterViewChecked detects when it appears.
|
|
if (this.mapInitialized) return;
|
|
if (!this.mapContainerRef) return;
|
|
// Verify the native element actually exists in the DOM (not just that
|
|
// the query ref was created — the @if block must have rendered too)
|
|
if (!this.mapContainerRef.nativeElement?.isConnected) return;
|
|
|
|
this.mapInitialized = true;
|
|
this.ngZone.runOutsideAngular(() => {
|
|
// Defer two frames so the browser has laid out the container
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => this._initMap());
|
|
});
|
|
});
|
|
}
|
|
|
|
private _initMap(): void {
|
|
this.map = L.map(this.mapContainerRef.nativeElement, {
|
|
center: [20, 0],
|
|
zoom: 2,
|
|
zoomControl: true,
|
|
worldCopyJump: true,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>',
|
|
maxZoom: 19,
|
|
}).addTo(this.map);
|
|
|
|
// Ensure Leaflet uses the correct container size
|
|
this.map.invalidateSize();
|
|
|
|
// Data may have loaded in ngOnInit before the view was ready
|
|
this._updateMapMarkers();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
if (this.map) {
|
|
this.map.remove();
|
|
this.map = null;
|
|
}
|
|
}
|
|
|
|
// ── Date helpers ────────────────────────────────────────
|
|
|
|
private _localDateStr(d: Date): string {
|
|
// Format as YYYY-MM-DD using local time (no UTC shift)
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
getDates(): { start: string; end: string } {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(today.getDate() - 1);
|
|
|
|
switch (this.selectedPreset) {
|
|
case 'yesterday':
|
|
return { start: this._localDateStr(yesterday), end: this._localDateStr(yesterday) };
|
|
case '7d': {
|
|
const start = new Date(yesterday);
|
|
start.setDate(yesterday.getDate() - 6);
|
|
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
|
}
|
|
case '30d': {
|
|
const start = new Date(yesterday);
|
|
start.setDate(yesterday.getDate() - 29);
|
|
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
|
}
|
|
case '90d': {
|
|
const start = new Date(yesterday);
|
|
start.setDate(yesterday.getDate() - 89);
|
|
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
|
}
|
|
case 'year': {
|
|
const start = new Date(yesterday);
|
|
start.setDate(yesterday.getDate() - 364);
|
|
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
|
}
|
|
case 'custom':
|
|
return {
|
|
start: this.customStart || this._localDateStr(this._daysAgo(30)),
|
|
end: this.customEnd || this._localDateStr(yesterday),
|
|
};
|
|
default:
|
|
return { start: this._localDateStr(yesterday), end: this._localDateStr(yesterday) };
|
|
}
|
|
}
|
|
|
|
private _daysAgo(n: number): Date {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
d.setDate(d.getDate() - n);
|
|
return d;
|
|
}
|
|
|
|
onPresetChange(): void {
|
|
this.loadAll();
|
|
}
|
|
|
|
// ── Data loading ────────────────────────────────────────
|
|
|
|
async loadAll(): Promise<void> {
|
|
this.loading = true;
|
|
this.error = null;
|
|
const { start, end } = this.getDates();
|
|
try {
|
|
const [summary, timeSeries, geoStats] = await Promise.all([
|
|
firstValueFrom(this.statsService.getSummary(start, end)),
|
|
firstValueFrom(this.statsService.getTimeSeries(start, end)),
|
|
firstValueFrom(this.statsService.getGeoStats(start, end)),
|
|
]);
|
|
|
|
// Angular 17+ uses fetch-based HTTP whose promise resolutions run
|
|
// outside the zone. Re-enter the zone and markForCheck() to force
|
|
// change detection through the OnPush parent chain.
|
|
this.ngZone.run(() => {
|
|
this.summary = summary;
|
|
this.timeSeries = timeSeries;
|
|
this.geoStats = geoStats;
|
|
|
|
this._updateChart();
|
|
this._updateMapMarkers();
|
|
this.cd.markForCheck();
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to load stats:', err);
|
|
this.ngZone.run(() => {
|
|
this.error = 'Failed to load stats data. Please check your connection and try again.';
|
|
this.cd.markForCheck();
|
|
});
|
|
} finally {
|
|
this.ngZone.run(() => {
|
|
this.loading = false;
|
|
this.cd.markForCheck();
|
|
});
|
|
}
|
|
}
|
|
|
|
async triggerParse(): Promise<void> {
|
|
this.parsing = true;
|
|
this.parseProgress = '';
|
|
|
|
const apiUrl = `${getAppConfig().apiBaseUrl}/api/stats/parse`;
|
|
// Append auth token as query param for SSE (EventSource can't send headers)
|
|
let token: string | null = null;
|
|
try {
|
|
token = localStorage.getItem('kmtn_access_token');
|
|
} catch {
|
|
// localStorage may be blocked
|
|
}
|
|
const url = token ? `${apiUrl}?token=${encodeURIComponent(token)}` : apiUrl;
|
|
|
|
return new Promise<void>((resolve) => {
|
|
const es = new EventSource(url);
|
|
|
|
es.addEventListener('progress', (e: MessageEvent) => {
|
|
const data = JSON.parse(e.data);
|
|
this.ngZone.run(() => {
|
|
this.parseProgress = `Parsing ${data.file}... ${data.current}/${data.total}`;
|
|
});
|
|
});
|
|
|
|
es.addEventListener('done', (e: MessageEvent) => {
|
|
const data = JSON.parse(e.data);
|
|
es.close();
|
|
this.ngZone.run(() => {
|
|
this.parseProgress = `Done — ${data.files_processed} files, ${data.rows_inserted} geo rows`;
|
|
this.parsing = false;
|
|
this.loadAll().then(() => resolve());
|
|
});
|
|
});
|
|
|
|
es.onerror = (e: Event) => {
|
|
console.error('Parse SSE error:', e);
|
|
es.close();
|
|
this.ngZone.run(() => {
|
|
this.parseProgress = 'Parse failed';
|
|
this.parsing = false;
|
|
resolve();
|
|
});
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
// ── Chart ───────────────────────────────────────────────
|
|
|
|
private _updateChart(): void {
|
|
this.chartData = {
|
|
labels: this.timeSeries.map((p) => this._formatDate(p.date)),
|
|
datasets: [
|
|
{
|
|
label: 'Unique Visitors',
|
|
data: this.timeSeries.map((p) => p.unique_visitors),
|
|
borderColor: '#1a3a5c',
|
|
backgroundColor: 'rgba(26, 58, 92, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 2,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
private _formatDate(dateStr: string): string {
|
|
const d = new Date(dateStr);
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
}
|
|
|
|
// ── Map ─────────────────────────────────────────────────
|
|
|
|
private _updateMapMarkers(): void {
|
|
if (!this.map) return;
|
|
|
|
// Remove old markers
|
|
if (this.markersLayer) {
|
|
this.map.removeLayer(this.markersLayer);
|
|
}
|
|
|
|
this.markersLayer = L.layerGroup();
|
|
|
|
const geoWithCoords = this.geoStats.filter((g) => g.latitude && g.longitude);
|
|
|
|
if (geoWithCoords.length === 0) {
|
|
this.markersLayer.addTo(this.map);
|
|
return;
|
|
}
|
|
|
|
const maxVisitors = Math.max(...geoWithCoords.map((g) => g.visitors), 1);
|
|
|
|
geoWithCoords.forEach((geo) => {
|
|
const radius = Math.max(5, Math.min(30, 5 + (geo.visitors / maxVisitors) * 25));
|
|
const marker = L.circleMarker([geo.latitude!, geo.longitude!], {
|
|
radius,
|
|
fillColor: '#e87a2e',
|
|
color: '#c85a1e',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.6,
|
|
});
|
|
marker.bindTooltip(`${geo.country}: ${geo.visitors} visitors`, {
|
|
sticky: true,
|
|
direction: 'top',
|
|
});
|
|
this.markersLayer?.addLayer(marker);
|
|
});
|
|
|
|
this.markersLayer.addTo(this.map);
|
|
}
|
|
} |