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: '© OpenStreetMap contributors © CARTO', 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 { 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 { 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((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); } }