Adds visitor dashboard and related features
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<div class="stats-dashboard">
|
||||
<!-- Time range selector -->
|
||||
<div class="stats-controls">
|
||||
<div class="preset-buttons">
|
||||
@for (preset of presets; track preset.value) {
|
||||
<button
|
||||
class="preset-btn"
|
||||
[class.active]="selectedPreset === preset.value"
|
||||
(click)="selectedPreset = preset.value; onPresetChange()"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedPreset === 'custom') {
|
||||
<div class="custom-dates">
|
||||
<label>
|
||||
From
|
||||
<input type="date" [(ngModel)]="customStart" (change)="onPresetChange()" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input type="date" [(ngModel)]="customEnd" (change)="onPresetChange()" />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-parse" (click)="triggerParse()" [disabled]="parsing">
|
||||
{{ parsing ? 'Parsing...' : 'Parse New Logs' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (parseProgress) {
|
||||
<div class="parse-progress">{{ parseProgress }}</div>
|
||||
}
|
||||
|
||||
@if (error) {
|
||||
<div class="error-message">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading-message">Loading stats...</div>
|
||||
} @else {
|
||||
<!-- Summary card -->
|
||||
<div class="stats-summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ summary?.total_unique_visitors ?? 0 }}</div>
|
||||
<div class="stat-label">Unique Visitors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ geoStats.length }}</div>
|
||||
<div class="stat-label">Countries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- World Map -->
|
||||
<div class="map-section">
|
||||
<h3>Visitors by Location</h3>
|
||||
<div class="map-container">
|
||||
<div #mapContainer class="map-leaflet"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitors Over Time Chart -->
|
||||
<div class="chart-section">
|
||||
<h3>Visitors Over Time</h3>
|
||||
<div class="chart-container">
|
||||
<canvas
|
||||
baseChart
|
||||
[data]="chartData"
|
||||
[options]="chartOptions"
|
||||
[type]="'line'"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geo data table -->
|
||||
<div class="geo-table-section">
|
||||
<h3>Visitors by Country</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>Code</th>
|
||||
<th>Visitors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (geo of geoStats; track geo.country_code) {
|
||||
<tr>
|
||||
<td>{{ geo.country }}</td>
|
||||
<td>{{ geo.country_code }}</td>
|
||||
<td>{{ geo.visitors }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="empty-row">No geo data available yet</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,197 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.stats-dashboard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────
|
||||
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-blue;
|
||||
border-color: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-dates {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-medium;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
input[type="date"] {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-parse {
|
||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.parse-progress {
|
||||
background: rgba($info-blue, 0.1);
|
||||
color: $info-blue;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(#d32f2f, 0.1);
|
||||
color: #d32f2f;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
// ── Summary cards ─────────────────────────────────────────
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-family: $font-heading;
|
||||
font-size: 2rem;
|
||||
color: $primary-blue;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────
|
||||
|
||||
.map-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 400px;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.map-leaflet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chart ─────────────────────────────────────────────────
|
||||
|
||||
.chart-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ── Geo table ─────────────────────────────────────────────
|
||||
|
||||
.geo-table-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Responsive ────────────────────────────────────────────
|
||||
|
||||
@include responsive(md) {
|
||||
.stats-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-parse {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Component, OnInit, inject, AfterViewInit, OnDestroy, 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 { 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, AfterViewInit, OnDestroy {
|
||||
private readonly statsService: StatsService = inject(StatsService);
|
||||
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;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Initialize Leaflet map
|
||||
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);
|
||||
}
|
||||
|
||||
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([
|
||||
this._firstValue(this.statsService.getSummary(start, end)),
|
||||
this._firstValue(this.statsService.getTimeSeries(start, end)),
|
||||
this._firstValue(this.statsService.getGeoStats(start, end)),
|
||||
]);
|
||||
|
||||
this.summary = summary;
|
||||
this.timeSeries = timeSeries;
|
||||
this.geoStats = geoStats;
|
||||
|
||||
this._updateChart();
|
||||
this._updateMapMarkers();
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
this.error = 'Failed to load stats data. Please check your connection and try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _firstValue<T>(obs: import('rxjs').Observable<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
obs.subscribe({ next: (v) => resolve(v), error: reject });
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@
|
||||
[class.active]="activeTab() === 'underwriters'"
|
||||
(click)="activeTab.set('underwriters')"
|
||||
>Underwriters</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
[class.active]="activeTab() === 'stats'"
|
||||
(click)="activeTab.set('stats')"
|
||||
>Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Shows Tab -->
|
||||
@@ -360,6 +365,13 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Stats Tab -->
|
||||
@if (activeTab() === 'stats') {
|
||||
<div class="tab-content">
|
||||
<app-admin-stats-dashboard />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form modals -->
|
||||
|
||||
@@ -24,8 +24,9 @@ import { AdminHistoryFormComponent } from './admin-history-form.component';
|
||||
import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters';
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
@@ -41,6 +42,7 @@ type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community'
|
||||
AdminTeamFormComponent,
|
||||
AdminCommunityFormComponent,
|
||||
AdminUnderwriterFormComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
],
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrl: './admin.component.scss',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface StatsSummary {
|
||||
total_unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
date: string;
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface GeoStat {
|
||||
country: string;
|
||||
country_code: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
visitors: number;
|
||||
}
|
||||
|
||||
export interface ParseProgress {
|
||||
file: string;
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
files_processed: number;
|
||||
rows_inserted: number;
|
||||
}
|
||||
|
||||
export type TimePreset = 'yesterday' | '7d' | '30d' | '90d' | 'year' | 'custom';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { getAppConfig } from './app-config.service';
|
||||
import {
|
||||
StatsSummary,
|
||||
TimeSeriesPoint,
|
||||
GeoStat,
|
||||
} from '../interfaces/visitor-stat';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StatsService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly baseUrl = `${getAppConfig().apiBaseUrl}/api/stats`;
|
||||
|
||||
getSummary(startDate: string, endDate: string): Observable<StatsSummary> {
|
||||
return this.http.get<StatsSummary>(`${this.baseUrl}/summary`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
|
||||
getTimeSeries(startDate: string, endDate: string): Observable<TimeSeriesPoint[]> {
|
||||
return this.http.get<TimeSeriesPoint[]>(`${this.baseUrl}/timeseries`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
|
||||
getGeoStats(startDate: string, endDate: string): Observable<GeoStat[]> {
|
||||
return this.http.get<GeoStat[]>(`${this.baseUrl}/geo`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user